diff --git a/lib/src/commonMain/kotlin/world/phantasmal/lib/assembly/Assembly.kt b/lib/src/commonMain/kotlin/world/phantasmal/lib/assembly/Assembly.kt index 99b5a73c..65580e93 100644 --- a/lib/src/commonMain/kotlin/world/phantasmal/lib/assembly/Assembly.kt +++ b/lib/src/commonMain/kotlin/world/phantasmal/lib/assembly/Assembly.kt @@ -70,39 +70,38 @@ private class Assembler(private val assembly: List, private val inlineSt var hasLabel = false when (token) { - is LabelToken -> { + is Token.Label -> { parseLabel(token) hasLabel = true } - is SectionToken, - -> { + is Token.Section -> { parseSection(token) } - is IntToken -> { + is Token.Int32 -> { if (section == SegmentType.Data) { parseBytes(token) } else { addUnexpectedTokenError(token) } } - is StringToken -> { + is Token.Str -> { if (section == SegmentType.String) { parseString(token) } else { addUnexpectedTokenError(token) } } - is IdentToken -> { + is Token.Ident -> { if (section === SegmentType.Instructions) { parseInstruction(token) } else { addUnexpectedTokenError(token) } } - is InvalidSectionToken -> { + is Token.InvalidSection -> { addError(token, "Invalid section type.") } - is InvalidIdentToken -> { + is Token.InvalidIdent -> { addError(token, "Invalid identifier.") } else -> { @@ -234,9 +233,11 @@ private class Assembler(private val assembly: List, private val inlineSt } private fun addUnexpectedTokenError(token: Token) { - addError(token, + addError( + token, "Unexpected token.", - "Unexpected ${token::class.simpleName} at ${token.srcLoc()}.") + "Unexpected ${token::class.simpleName} at ${token.srcLoc()}.", + ) } private fun addWarning(token: Token, uiMessage: String) { @@ -246,12 +247,12 @@ private class Assembler(private val assembly: List, private val inlineSt uiMessage, lineNo = lineNo, col = token.col, - length = token.len + length = token.len, ) ) } - private fun parseLabel(token: LabelToken) { + private fun parseLabel(token: Token.Label) { val label = token.value if (!labels.add(label)) { @@ -281,7 +282,7 @@ private class Assembler(private val assembly: List, private val inlineSt } if (nextToken != null) { - if (nextToken is IdentToken) { + if (nextToken is Token.Ident) { parseInstruction(nextToken) } else { addError(nextToken, "Expected opcode mnemonic.") @@ -300,7 +301,7 @@ private class Assembler(private val assembly: List, private val inlineSt } if (nextToken != null) { - if (nextToken is IntToken) { + if (nextToken is Token.Int32) { parseBytes(nextToken) } else { addError(nextToken, "Expected bytes.") @@ -319,7 +320,7 @@ private class Assembler(private val assembly: List, private val inlineSt } if (nextToken != null) { - if (nextToken is StringToken) { + if (nextToken is Token.Str) { parseString(nextToken) } else { addError(nextToken, "Expected a string.") @@ -329,11 +330,11 @@ private class Assembler(private val assembly: List, private val inlineSt } } - private fun parseSection(token: SectionToken) { + private fun parseSection(token: Token.Section) { val section = when (token) { - is CodeSectionToken -> SegmentType.Instructions - is DataSectionToken -> SegmentType.Data - is StringSectionToken -> SegmentType.String + is Token.Section.Code -> SegmentType.Instructions + is Token.Section.Data -> SegmentType.Data + is Token.Section.Str -> SegmentType.String } if (this.section == section && !firstSectionMarker) { @@ -348,11 +349,11 @@ private class Assembler(private val assembly: List, private val inlineSt } } - private fun parseInstruction(identToken: IdentToken) { + private fun parseInstruction(identToken: Token.Ident) { val opcode = mnemonicToOpcode(identToken.value) if (opcode == null) { - addError(identToken, "Unknown instruction.") + addError(identToken, "Unknown opcode.") } else { val varargs = opcode.params.any { it.type is ILabelVarType || it.type is RegRefVarType @@ -362,7 +363,7 @@ private class Assembler(private val assembly: List, private val inlineSt if (!inlineStackArgs && opcode.stack == StackInteraction.Pop) 0 else opcode.params.size - val argCount = tokens.count { it !is ArgSeparatorToken } + val argCount = tokens.count { it !is Token.ArgSeparator } val lastToken = tokens.lastOrNull() val errorLength = lastToken?.let { it.col + it.len - identToken.col } ?: 0 @@ -375,11 +376,13 @@ private class Assembler(private val assembly: List, private val inlineSt addError( identToken.col, errorLength, - "Expected $paramCount argument ${if (paramCount == 1) "" else "s"}, got $argCount." + "Expected $paramCount argument ${if (paramCount == 1) "" else "s"}, got $argCount.", ) return } else if (varargs && argCount < paramCount) { + // TODO: This check assumes we want at least 1 argument for a vararg parameter. + // Is this correct? addError( identToken.col, errorLength, @@ -388,12 +391,13 @@ private class Assembler(private val assembly: List, private val inlineSt return } else if (opcode.stack !== StackInteraction.Pop) { - // Inline arguments. + // Arguments should be inlined right after the opcode. if (!parseArgs(opcode.params, insArgAndTokens, stack = false)) { return } } else { - if (!this.parseArgs(opcode.params, stackArgAndTokens, stack = true)) { + // Arguments should be passed to the opcode via the stack. + if (!parseArgs(opcode.params, stackArgAndTokens, stack = true)) { return } @@ -402,7 +406,7 @@ private class Assembler(private val assembly: List, private val inlineSt val argAndToken = stackArgAndTokens.getOrNull(i) ?: continue val (arg, argToken) = argAndToken - if (argToken is RegisterToken) { + if (argToken is Token.Register) { if (param.type is RegTupRefType) { addInstruction( OP_ARG_PUSHB, @@ -527,7 +531,7 @@ private class Assembler(private val assembly: List, private val inlineSt val token = tokens[i] val param = params[paramI] - if (token is ArgSeparatorToken) { + if (token is Token.ArgSeparator) { if (shouldBeArg) { addError(token, "Expected an argument.") } else if ( @@ -551,7 +555,7 @@ private class Assembler(private val assembly: List, private val inlineSt var match: Boolean when (token) { - is IntToken -> { + is Token.Int32 -> { when (param.type) { is ByteType -> { match = true @@ -581,7 +585,7 @@ private class Assembler(private val assembly: List, private val inlineSt } } - is FloatToken -> { + is Token.Float32 -> { match = param.type == FloatType if (match) { @@ -589,7 +593,7 @@ private class Assembler(private val assembly: List, private val inlineSt } } - is RegisterToken -> { + is Token.Register -> { match = stack || param.type is RegRefType || param.type is RegRefVarType || @@ -598,7 +602,7 @@ private class Assembler(private val assembly: List, private val inlineSt parseRegister(token, argAndTokens) } - is StringToken -> { + is Token.Str -> { match = param.type is StringType if (match) { @@ -649,7 +653,11 @@ private class Assembler(private val assembly: List, private val inlineSt return semiValid } - private fun parseInt(size: Int, token: IntToken, argAndTokens: MutableList>) { + private fun parseInt( + size: Int, + token: Token.Int32, + argAndTokens: MutableList>, + ) { val value = token.value val bitSize = 8 * size // Minimum of the signed version of this integer type. @@ -670,7 +678,7 @@ private class Assembler(private val assembly: List, private val inlineSt } } - private fun parseRegister(token: RegisterToken, argAndTokens: MutableList>) { + private fun parseRegister(token: Token.Register, argAndTokens: MutableList>) { val value = token.value if (value > 255) { @@ -680,12 +688,12 @@ private class Assembler(private val assembly: List, private val inlineSt } } - private fun parseBytes(firstToken: IntToken) { + private fun parseBytes(firstToken: Token.Int32) { val bytes = mutableListOf() var token: Token = firstToken var i = 0 - while (token is IntToken) { + while (token is Token.Int32) { if (token.value < 0) { addError(token, "Unsigned 8-bit integer can't be less than 0.") } else if (token.value > 255) { @@ -708,7 +716,7 @@ private class Assembler(private val assembly: List, private val inlineSt addBytes(bytes.toByteArray()) } - private fun parseString(token: StringToken) { + private fun parseString(token: Token.Str) { tokens.removeFirstOrNull()?.let { nextToken -> addUnexpectedTokenError(nextToken) } diff --git a/lib/src/commonMain/kotlin/world/phantasmal/lib/assembly/AssemblyTokenization.kt b/lib/src/commonMain/kotlin/world/phantasmal/lib/assembly/AssemblyTokenization.kt index 3c8bef2b..875a5fd4 100644 --- a/lib/src/commonMain/kotlin/world/phantasmal/lib/assembly/AssemblyTokenization.kt +++ b/lib/src/commonMain/kotlin/world/phantasmal/lib/assembly/AssemblyTokenization.kt @@ -6,89 +6,89 @@ private val HEX_INT_REGEX = Regex("""^0x[\da-fA-F]+$""") private val FLOAT_REGEX = Regex("""^-?\d+(\.\d+)?(e-?\d+)?$""") private val IDENT_REGEX = Regex("""^[a-z][a-z0-9_=<>!]*$""") -sealed class Token( - val col: Int, - val len: Int, -) +sealed class Token { + abstract val col: Int + abstract val len: Int -class IntToken( - col: Int, - len: Int, - val value: Int, -) : Token(col, len) + class Int32( + override val col: Int, + override val len: Int, + val value: Int, + ) : Token() -class FloatToken( - col: Int, - len: Int, - val value: Float, -) : Token(col, len) + class Float32( + override val col: Int, + override val len: Int, + val value: Float, + ) : Token() -class InvalidNumberToken( - col: Int, - len: Int, -) : Token(col, len) + class InvalidNumber( + override val col: Int, + override val len: Int, + ) : Token() -class RegisterToken( - col: Int, - len: Int, - val value: Int, -) : Token(col, len) + class Register( + override val col: Int, + override val len: Int, + val value: Int, + ) : Token() -class LabelToken( - col: Int, - len: Int, - val value: Int, -) : Token(col, len) + class Label( + override val col: Int, + override val len: Int, + val value: Int, + ) : Token() -sealed class SectionToken(col: Int, len: Int) : Token(col, len) + sealed class Section : Token() { + class Code( + override val col: Int, + override val len: Int, + ) : Section() -class CodeSectionToken( - col: Int, - len: Int, -) : SectionToken(col, len) + class Data( + override val col: Int, + override val len: Int, + ) : Section() -class DataSectionToken( - col: Int, - len: Int, -) : SectionToken(col, len) + class Str( + override val col: Int, + override val len: Int, + ) : Section() + } -class StringSectionToken( - col: Int, - len: Int, -) : SectionToken(col, len) + class InvalidSection( + override val col: Int, + override val len: Int, + ) : Token() -class InvalidSectionToken( - col: Int, - len: Int, -) : Token(col, len) + class Str( + override val col: Int, + override val len: Int, + val value: String, + ) : Token() -class StringToken( - col: Int, - len: Int, - val value: String, -) : Token(col, len) + class UnterminatedString( + override val col: Int, + override val len: Int, + val value: String, + ) : Token() -class UnterminatedStringToken( - col: Int, - len: Int, - val value: String, -) : Token(col, len) + class Ident( + override val col: Int, + override val len: Int, + val value: String, + ) : Token() -class IdentToken( - col: Int, - len: Int, - val value: String, -) : Token(col, len) + class InvalidIdent( + override val col: Int, + override val len: Int, + ) : Token() -class InvalidIdentToken( - col: Int, - len: Int, -) : Token(col, len) - -class ArgSeparatorToken( - col: Int, - len: Int, -) : Token(col, len) + class ArgSeparator( + override val col: Int, + override val len: Int, + ) : Token() +} fun tokenizeLine(line: String): MutableList = LineTokenizer(line).tokenize() @@ -125,7 +125,7 @@ private class LineTokenizer(private var line: String) { } else if (char == '-' || char.isDigit()) { token = tokenizeNumberOrLabel() } else if (char == ',') { - token = ArgSeparatorToken(col, 1) + token = Token.ArgSeparator(col, 1) skip() } else if (char == '.') { token = tokenizeSection() @@ -206,13 +206,13 @@ private class LineTokenizer(private var line: String) { } if (value == null) { - return InvalidNumberToken(col, markedLen()) + return Token.InvalidNumber(col, markedLen()) } return if (isLabel) { - LabelToken(col, markedLen(), value) + Token.Label(col, markedLen(), value) } else { - IntToken(col, markedLen(), value) + Token.Int32(col, markedLen(), value) } } @@ -222,11 +222,11 @@ private class LineTokenizer(private var line: String) { if (HEX_INT_REGEX.matches(hexStr)) { hexStr.toIntOrNull(16)?.let { value -> - return IntToken(col, markedLen(), value) + return Token.Int32(col, markedLen(), value) } } - return InvalidNumberToken(col, markedLen()) + return Token.InvalidNumber(col, markedLen()) } private fun tokenizeFloat(col: Int): Token { @@ -235,11 +235,11 @@ private class LineTokenizer(private var line: String) { if (FLOAT_REGEX.matches(floatStr)) { floatStr.toFloatOrNull()?.let { value -> - return FloatToken(col, markedLen(), value) + return Token.Float32(col, markedLen(), value) } } - return InvalidNumberToken(col, markedLen()) + return Token.InvalidNumber(col, markedLen()) } private fun tokenizeRegisterOrIdent(): Token { @@ -262,7 +262,7 @@ private class LineTokenizer(private var line: String) { return if (isRegister) { val value = slice().toInt() - RegisterToken(col, markedLen() + 1, value) + Token.Register(col, markedLen() + 1, value) } else { back() tokenizeIdent() @@ -282,10 +282,10 @@ private class LineTokenizer(private var line: String) { } return when (slice()) { - ".code" -> CodeSectionToken(col, 5) - ".data" -> DataSectionToken(col, 5) - ".string" -> StringSectionToken(col, 7) - else -> InvalidSectionToken(col, markedLen()) + ".code" -> Token.Section.Code(col, 5) + ".data" -> Token.Section.Data(col, 5) + ".string" -> Token.Section.Str(col, 7) + else -> Token.InvalidSection(col, markedLen()) } } @@ -321,9 +321,9 @@ private class LineTokenizer(private var line: String) { return if (terminated) { next() - StringToken(col, markedLen() + 2, value) + Token.Str(col, markedLen() + 2, value) } else { - UnterminatedStringToken(col, markedLen() + 1, value) + Token.UnterminatedString(col, markedLen() + 1, value) } } @@ -351,9 +351,9 @@ private class LineTokenizer(private var line: String) { val value = slice() return if (IDENT_REGEX.matches(value)) { - IdentToken(col, markedLen(), value) + Token.Ident(col, markedLen(), value) } else { - InvalidIdentToken(col, markedLen()) + Token.InvalidIdent(col, markedLen()) } } } diff --git a/lib/src/commonMain/kotlin/world/phantasmal/lib/cursor/AbstractWritableCursor.kt b/lib/src/commonMain/kotlin/world/phantasmal/lib/cursor/AbstractWritableCursor.kt index ba9a89e6..4029a7b3 100644 --- a/lib/src/commonMain/kotlin/world/phantasmal/lib/cursor/AbstractWritableCursor.kt +++ b/lib/src/commonMain/kotlin/world/phantasmal/lib/cursor/AbstractWritableCursor.kt @@ -14,8 +14,8 @@ protected constructor(protected val offset: Int) : WritableCursor { protected val absolutePosition: Int get() = offset + position - override fun hasBytesLeft(atLeast: Int): Boolean = - bytesLeft >= atLeast + override fun hasBytesLeft(): Boolean = + bytesLeft > 0 override fun seek(offset: Int): WritableCursor = seekStart(position + offset) diff --git a/lib/src/commonMain/kotlin/world/phantasmal/lib/cursor/Cursor.kt b/lib/src/commonMain/kotlin/world/phantasmal/lib/cursor/Cursor.kt index 69991a13..25a699fa 100644 --- a/lib/src/commonMain/kotlin/world/phantasmal/lib/cursor/Cursor.kt +++ b/lib/src/commonMain/kotlin/world/phantasmal/lib/cursor/Cursor.kt @@ -21,7 +21,7 @@ interface Cursor { val bytesLeft: Int - fun hasBytesLeft(atLeast: Int = 1): Boolean + fun hasBytesLeft(): Boolean /** * Seek forward or backward by a number of bytes. diff --git a/lib/src/commonMain/kotlin/world/phantasmal/lib/fileFormats/Afs.kt b/lib/src/commonMain/kotlin/world/phantasmal/lib/fileFormats/Afs.kt new file mode 100644 index 00000000..e88bdf5c --- /dev/null +++ b/lib/src/commonMain/kotlin/world/phantasmal/lib/fileFormats/Afs.kt @@ -0,0 +1,86 @@ +package world.phantasmal.lib.fileFormats + +import mu.KotlinLogging +import world.phantasmal.core.PwResult +import world.phantasmal.core.Severity +import world.phantasmal.lib.buffer.Buffer +import world.phantasmal.lib.cursor.Cursor + +private val logger = KotlinLogging.logger {} + +private const val AFS = 0x00534641 + +/** + * Returns the files contained in the given AFS archive. AFS is a simple archive format used by SEGA + * for e.g. player character textures. + */ +fun parseAfs(cursor: Cursor): PwResult> { + val result = PwResult.build>(logger) + + if (cursor.bytesLeft < 8) { + return result + .addProblem( + Severity.Error, + "AFS archive is corrupted.", + "Expected at least 8 bytes for the header, got ${cursor.bytesLeft} bytes.", + ) + .failure() + } + + val magic = cursor.int() + + if (magic != AFS) { + return result + .addProblem(Severity.Error, "AFS archive is corrupted.", "Magic bytes not present.") + .failure() + } + + val fileCount = cursor.short() + + // Skip two unused bytes (are these just part of the file count field?). + cursor.seek(2) + + val files = mutableListOf() + + for (i in 1..fileCount) { + if (cursor.bytesLeft < 8) { + result.addProblem( + Severity.Warning, + "AFS file entry $i is invalid.", + "Couldn't read file entry $i, only ${cursor.bytesLeft} bytes left.", + ) + + break + } + + val offset = cursor.int() + val size = cursor.int() + + when { + offset > cursor.size -> { + result.addProblem( + Severity.Warning, + "AFS file entry $i is invalid.", + "Invalid file offset $offset for entry $i.", + ) + } + + offset + size > cursor.size -> { + result.addProblem( + Severity.Warning, + "AFS file entry $i is invalid.", + "File size $size (offset: $offset) of entry $i too large.", + ) + } + + else -> { + val startPos = cursor.position + cursor.seekStart(offset) + files.add(cursor.buffer(size)) + cursor.seekStart(startPos) + } + } + } + + return result.success(files) +} diff --git a/lib/src/commonMain/kotlin/world/phantasmal/lib/fileFormats/quest/EntityType.kt b/lib/src/commonMain/kotlin/world/phantasmal/lib/fileFormats/quest/EntityType.kt index b6617334..09b40201 100644 --- a/lib/src/commonMain/kotlin/world/phantasmal/lib/fileFormats/quest/EntityType.kt +++ b/lib/src/commonMain/kotlin/world/phantasmal/lib/fileFormats/quest/EntityType.kt @@ -1,10 +1,13 @@ package world.phantasmal.lib.fileFormats.quest interface EntityType { + val name: String + /** * Unique name. E.g. an episode II Delsaber would have (Ep. II) appended to its name. */ val uniqueName: String + /** * Name used in the game. * Might conflict with other NPC names (e.g. Delsaber from ep. I and ep. II). diff --git a/lib/src/commonMain/kotlin/world/phantasmal/lib/fileFormats/quest/Qst.kt b/lib/src/commonMain/kotlin/world/phantasmal/lib/fileFormats/quest/Qst.kt index c6bd1942..8ea6cb32 100644 --- a/lib/src/commonMain/kotlin/world/phantasmal/lib/fileFormats/quest/Qst.kt +++ b/lib/src/commonMain/kotlin/world/phantasmal/lib/fileFormats/quest/Qst.kt @@ -285,7 +285,7 @@ private fun parseFiles( } } - while (cursor.hasBytesLeft(chunkSize)) { + while (cursor.bytesLeft >= chunkSize) { val startPosition = cursor.position // Read chunk header. diff --git a/lib/src/commonMain/kotlin/world/phantasmal/lib/fileFormats/quest/QuestObject.kt b/lib/src/commonMain/kotlin/world/phantasmal/lib/fileFormats/quest/QuestObject.kt index 4d4e5f93..1752c5d7 100644 --- a/lib/src/commonMain/kotlin/world/phantasmal/lib/fileFormats/quest/QuestObject.kt +++ b/lib/src/commonMain/kotlin/world/phantasmal/lib/fileFormats/quest/QuestObject.kt @@ -7,6 +7,11 @@ import world.phantasmal.lib.fileFormats.ninja.radToAngle import kotlin.math.roundToInt class QuestObject(override var areaId: Int, val data: Buffer) : QuestEntity { + constructor(type: ObjectType, areaId: Int) : this(areaId, Buffer.withSize(OBJECT_BYTE_SIZE)) { + // TODO: Set default data. + this.type = type + } + var typeId: Int get() = data.getShort(0).toInt() set(value) { diff --git a/lib/src/commonTest/kotlin/world/phantasmal/lib/assembly/AssemblyTokenizationTests.kt b/lib/src/commonTest/kotlin/world/phantasmal/lib/assembly/AssemblyTokenizationTests.kt index 6daf1cd0..4db97a08 100644 --- a/lib/src/commonTest/kotlin/world/phantasmal/lib/assembly/AssemblyTokenizationTests.kt +++ b/lib/src/commonTest/kotlin/world/phantasmal/lib/assembly/AssemblyTokenizationTests.kt @@ -7,40 +7,40 @@ import kotlin.test.assertEquals class AssemblyTokenizationTests : LibTestSuite() { @Test - fun valid_floats_are_parsed_as_FloatTokens() { - assertCloseTo(808.9f, (tokenizeLine("808.9")[0] as FloatToken).value) - assertCloseTo(-0.9f, (tokenizeLine("-0.9")[0] as FloatToken).value) - assertCloseTo(0.001f, (tokenizeLine("1e-3")[0] as FloatToken).value) - assertCloseTo(-600.0f, (tokenizeLine("-6e2")[0] as FloatToken).value) + fun valid_floats_are_parsed_as_Float32_tokens() { + assertCloseTo(808.9f, (tokenizeLine("808.9")[0] as Token.Float32).value) + assertCloseTo(-0.9f, (tokenizeLine("-0.9")[0] as Token.Float32).value) + assertCloseTo(0.001f, (tokenizeLine("1e-3")[0] as Token.Float32).value) + assertCloseTo(-600.0f, (tokenizeLine("-6e2")[0] as Token.Float32).value) } @Test - fun invalid_floats_area_parsed_as_InvalidNumberTokens_or_InvalidSectionTokens() { + fun invalid_floats_area_parsed_as_InvalidNumber_tokens_or_InvalidSection_tokens() { val tokens1 = tokenizeLine(" 808.9a ") assertEquals(1, tokens1.size) - assertEquals(InvalidNumberToken::class, tokens1[0]::class) + assertEquals(Token.InvalidNumber::class, tokens1[0]::class) assertEquals(2, tokens1[0].col) assertEquals(6, tokens1[0].len) val tokens2 = tokenizeLine(" -55e ") assertEquals(1, tokens2.size) - assertEquals(InvalidNumberToken::class, tokens2[0]::class) + assertEquals(Token.InvalidNumber::class, tokens2[0]::class) assertEquals(3, tokens2[0].col) assertEquals(4, tokens2[0].len) val tokens3 = tokenizeLine(".7429") assertEquals(1, tokens3.size) - assertEquals(InvalidSectionToken::class, tokens3[0]::class) + assertEquals(Token.InvalidSection::class, tokens3[0]::class) assertEquals(1, tokens3[0].col) assertEquals(5, tokens3[0].len) val tokens4 = tokenizeLine("\t\t\t4. test") assertEquals(2, tokens4.size) - assertEquals(InvalidNumberToken::class, tokens4[0]::class) + assertEquals(Token.InvalidNumber::class, tokens4[0]::class) assertEquals(4, tokens4[0].col) assertEquals(2, tokens4[0].len) } diff --git a/observable/src/commonMain/kotlin/world/phantasmal/observable/value/list/FilteredListVal.kt b/observable/src/commonMain/kotlin/world/phantasmal/observable/value/list/FilteredListVal.kt new file mode 100644 index 00000000..2e900bf6 --- /dev/null +++ b/observable/src/commonMain/kotlin/world/phantasmal/observable/value/list/FilteredListVal.kt @@ -0,0 +1,145 @@ +package world.phantasmal.observable.value.list + +import world.phantasmal.core.disposable.Disposable +import world.phantasmal.core.disposable.disposable +import world.phantasmal.observable.Observer +import world.phantasmal.observable.value.AbstractVal +import world.phantasmal.observable.value.Val + +// TODO: This class shares 95% of its code with DependentListVal. +class FilteredListVal( + private val dependency: ListVal, + private val predicate: (E) -> Boolean, +) : AbstractListVal(mutableListOf(), extractObservables = null) { + private val _sizeVal = SizeVal() + + /** + * Set to true right before actual observers are added. + */ + private var hasObservers = false + + private var dependencyObserver: Disposable? = null + + override val value: List + get() { + if (!hasObservers) { + recompute() + } + + return elements + } + + override val sizeVal: Val = _sizeVal + + override fun observe(callNow: Boolean, observer: Observer>): Disposable { + initDependencyObservers() + + val superDisposable = super.observe(callNow, observer) + + return disposable { + superDisposable.dispose() + disposeDependencyObservers() + } + } + + override fun observeList(callNow: Boolean, observer: ListValObserver): Disposable { + initDependencyObservers() + + val superDisposable = super.observeList(callNow, observer) + + return disposable { + superDisposable.dispose() + disposeDependencyObservers() + } + } + + private fun recompute() { + elements.clear() + dependency.value.filterTo(elements, predicate) + } + + private fun initDependencyObservers() { + if (dependencyObserver == null) { + hasObservers = true + + dependencyObserver = dependency.observeList { event -> + when (event) { + is ListValChangeEvent.Change -> { + var index = 0 + + repeat(event.index) { i -> + if (predicate(dependency[i])) { + index++ + } + } + + val removed = mutableListOf() + + event.removed.forEach { element -> + if (predicate(element)) { + removed.add(elements.removeAt(index)) + } + } + + val inserted = event.inserted.filter(predicate) + elements.addAll(index, inserted) + + if (removed.isNotEmpty() || inserted.isNotEmpty()) { + finalizeUpdate(ListValChangeEvent.Change(index, removed, inserted)) + } + } + + is ListValChangeEvent.ElementChange -> { + finalizeUpdate(event) + } + } + } + + recompute() + } + } + + private fun disposeDependencyObservers() { + if (observers.isEmpty() && listObservers.isEmpty() && _sizeVal.publicObservers.isEmpty()) { + hasObservers = false + dependencyObserver?.dispose() + dependencyObserver = null + } + } + + override fun finalizeUpdate(event: ListValChangeEvent) { + if (event is ListValChangeEvent.Change && event.removed.size != event.inserted.size) { + _sizeVal.publicEmit() + } + + super.finalizeUpdate(event) + } + + private inner class SizeVal : AbstractVal() { + override val value: Int + get() { + if (!hasObservers) { + recompute() + } + + return elements.size + } + + val publicObservers = super.observers + + override fun observe(callNow: Boolean, observer: Observer): Disposable { + initDependencyObservers() + + val superDisposable = super.observe(callNow, observer) + + return disposable { + superDisposable.dispose() + disposeDependencyObservers() + } + } + + fun publicEmit() { + super.emit() + } + } +} diff --git a/observable/src/commonMain/kotlin/world/phantasmal/observable/value/list/ListVal.kt b/observable/src/commonMain/kotlin/world/phantasmal/observable/value/list/ListVal.kt index 9e343593..5be2ec64 100644 --- a/observable/src/commonMain/kotlin/world/phantasmal/observable/value/list/ListVal.kt +++ b/observable/src/commonMain/kotlin/world/phantasmal/observable/value/list/ListVal.kt @@ -17,5 +17,5 @@ interface ListVal : Val> { FoldedVal(this, initialValue, operation) fun filtered(predicate: (E) -> Boolean): ListVal = - DependentListVal(listOf(this)) { value.filter(predicate) } + FilteredListVal(this, predicate) } diff --git a/observable/src/commonMain/kotlin/world/phantasmal/observable/value/list/MutableListVal.kt b/observable/src/commonMain/kotlin/world/phantasmal/observable/value/list/MutableListVal.kt index 9d6499a3..aab8b2fe 100644 --- a/observable/src/commonMain/kotlin/world/phantasmal/observable/value/list/MutableListVal.kt +++ b/observable/src/commonMain/kotlin/world/phantasmal/observable/value/list/MutableListVal.kt @@ -9,6 +9,8 @@ interface MutableListVal : ListVal, MutableVal> { fun add(index: Int, element: E) + fun remove(element: E): Boolean + fun removeAt(index: Int): E fun replaceAll(elements: Iterable) diff --git a/observable/src/commonMain/kotlin/world/phantasmal/observable/value/list/SimpleListVal.kt b/observable/src/commonMain/kotlin/world/phantasmal/observable/value/list/SimpleListVal.kt index 59cc47be..86804f83 100644 --- a/observable/src/commonMain/kotlin/world/phantasmal/observable/value/list/SimpleListVal.kt +++ b/observable/src/commonMain/kotlin/world/phantasmal/observable/value/list/SimpleListVal.kt @@ -45,6 +45,17 @@ class SimpleListVal( finalizeUpdate(ListValChangeEvent.Change(index, emptyList(), listOf(element))) } + override fun remove(element: E): Boolean { + val index = elements.indexOf(element) + + return if (index != -1) { + removeAt(index) + true + } else { + false + } + } + override fun removeAt(index: Int): E { val removed = elements.removeAt(index) finalizeUpdate(ListValChangeEvent.Change(index, listOf(removed), emptyList())) diff --git a/observable/src/commonTest/kotlin/world/phantasmal/observable/value/list/FilteredListValTests.kt b/observable/src/commonTest/kotlin/world/phantasmal/observable/value/list/FilteredListValTests.kt new file mode 100644 index 00000000..61a692f4 --- /dev/null +++ b/observable/src/commonTest/kotlin/world/phantasmal/observable/value/list/FilteredListValTests.kt @@ -0,0 +1,76 @@ +package world.phantasmal.observable.value.list + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class FilteredListValTests : ListValTests() { + @Test + fun only_emits_when_necessary() = test { + val dep = SimpleListVal(mutableListOf()) + val list = FilteredListVal(dep) { it % 2 == 0 } + var changes = 0 + var listChanges = 0 + + disposer.add(list.observe { + changes++ + }) + disposer.add(list.observeList { + listChanges++ + }) + + dep.add(1) + dep.add(3) + dep.add(5) + + assertEquals(0, changes) + assertEquals(0, listChanges) + + dep.add(0) + dep.add(2) + dep.add(4) + + assertEquals(3, changes) + assertEquals(3, listChanges) + } + + @Test + fun emits_correct_change_events() = test { + val dep = SimpleListVal(mutableListOf()) + val list = FilteredListVal(dep) { it % 2 == 0 } + var event: ListValChangeEvent? = null + + disposer.add(list.observeList { + assertNull(event) + event = it + }) + + dep.replaceAll(listOf(1, 2, 3, 4, 5)) + + (event as ListValChangeEvent.Change).let { e -> + assertEquals(0, e.index) + assertEquals(0, e.removed.size) + assertEquals(2, e.inserted.size) + assertEquals(2, e.inserted[0]) + assertEquals(4, e.inserted[1]) + } + + event = null + + dep.splice(2, 2, 10) + + (event as ListValChangeEvent.Change).let { e -> + assertEquals(1, e.index) + assertEquals(1, e.removed.size) + assertEquals(4, e.removed[0]) + assertEquals(1, e.inserted.size) + assertEquals(10, e.inserted[0]) + } + } + + override fun create(): ListValAndAdd<*, FilteredListVal<*>> { + val l = SimpleListVal(mutableListOf()) + val list = FilteredListVal(l) { it % 2 == 0 } + return ListValAndAdd(list) { l.add(4) } + } +} diff --git a/test-utils/src/commonMain/kotlin/world/phantasmal/testUtils/TestContext.kt b/test-utils/src/commonMain/kotlin/world/phantasmal/testUtils/TestContext.kt index 6ff678a9..10135dbe 100644 --- a/test-utils/src/commonMain/kotlin/world/phantasmal/testUtils/TestContext.kt +++ b/test-utils/src/commonMain/kotlin/world/phantasmal/testUtils/TestContext.kt @@ -1,11 +1,5 @@ package world.phantasmal.testUtils -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Job import world.phantasmal.core.disposable.Disposer -open class TestContext(val disposer: Disposer) { - val scope: CoroutineScope = object : CoroutineScope { - override val coroutineContext = Job() - } -} +open class TestContext(val disposer: Disposer) diff --git a/web/src/main/kotlin/world/phantasmal/web/Main.kt b/web/src/main/kotlin/world/phantasmal/web/Main.kt index 23e178a3..345d01e0 100644 --- a/web/src/main/kotlin/world/phantasmal/web/Main.kt +++ b/web/src/main/kotlin/world/phantasmal/web/Main.kt @@ -5,8 +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.SupervisorJob import kotlinx.coroutines.cancel import kotlinx.datetime.Clock import mu.KotlinLoggingConfiguration @@ -58,12 +56,8 @@ private fun init(): Disposable { } disposer.add(disposable { httpClient.cancel() }) - val scope = CoroutineScope(SupervisorJob()) - disposer.add(disposable { scope.cancel() }) - disposer.add( Application( - scope, rootElement, AssetLoader(httpClient), disposer.add(HistoryApplicationUrl()), @@ -98,7 +92,7 @@ private class HistoryApplicationUrl : TrackedDisposable(), ApplicationUrl { override val url = mutableVal(window.location.hash.substring(1)) - private val popStateListener = disposableListener(window, "popstate", { + private val popStateListener = window.disposableListener("popstate", { url.value = window.location.hash.substring(1) }) diff --git a/web/src/main/kotlin/world/phantasmal/web/application/Application.kt b/web/src/main/kotlin/world/phantasmal/web/application/Application.kt index 8d68c9a1..985c4a2c 100644 --- a/web/src/main/kotlin/world/phantasmal/web/application/Application.kt +++ b/web/src/main/kotlin/world/phantasmal/web/application/Application.kt @@ -1,7 +1,6 @@ package world.phantasmal.web.application import kotlinx.browser.document -import kotlinx.coroutines.CoroutineScope import kotlinx.datetime.Clock import org.w3c.dom.DragEvent import org.w3c.dom.HTMLCanvasElement @@ -25,7 +24,6 @@ import world.phantasmal.webui.DisposableContainer import world.phantasmal.webui.dom.disposableListener class Application( - scope: CoroutineScope, rootElement: HTMLElement, assetLoader: AssetLoader, applicationUrl: ApplicationUrl, @@ -35,19 +33,19 @@ class Application( init { addDisposables( // Disable native undo/redo. - disposableListener(document, "beforeinput", ::beforeInput), + document.disposableListener("beforeinput", ::beforeInput), // Work-around for FireFox: - disposableListener(document, "keydown", ::keydown), + document.disposableListener("keydown", ::keydown), // Disable native drag-and-drop to avoid users dragging in unsupported file formats and // leaving the application unexpectedly. - disposableListener(document, "dragenter", ::dragenter), - disposableListener(document, "dragover", ::dragover), - disposableListener(document, "drop", ::drop), + document.disposableListener("dragenter", ::dragenter), + document.disposableListener("dragover", ::dragover), + document.disposableListener("drop", ::drop), ) // Initialize core stores shared by several submodules. - val uiStore = addDisposable(UiStore(scope, applicationUrl)) + val uiStore = addDisposable(UiStore(applicationUrl)) // The various tools Phantasmal World consists of. val tools: List = listOf( @@ -63,10 +61,8 @@ class Application( // Initialize application view. val applicationWidget = addDisposable( ApplicationWidget( - scope, - NavigationWidget(scope, navigationController), + NavigationWidget(navigationController), MainContentWidget( - scope, mainContentController, tools.map { it.toolType to it::initialize }.toMap() ) diff --git a/web/src/main/kotlin/world/phantasmal/web/application/widgets/ApplicationWidget.kt b/web/src/main/kotlin/world/phantasmal/web/application/widgets/ApplicationWidget.kt index a34e4059..2bec0dd9 100644 --- a/web/src/main/kotlin/world/phantasmal/web/application/widgets/ApplicationWidget.kt +++ b/web/src/main/kotlin/world/phantasmal/web/application/widgets/ApplicationWidget.kt @@ -1,15 +1,13 @@ package world.phantasmal.web.application.widgets -import kotlinx.coroutines.CoroutineScope import org.w3c.dom.Node import world.phantasmal.webui.dom.div import world.phantasmal.webui.widgets.Widget class ApplicationWidget( - scope: CoroutineScope, private val navigationWidget: NavigationWidget, private val mainContentWidget: MainContentWidget, -) : Widget(scope) { +) : Widget() { override fun Node.createElement() = div { className = "pw-application-application" diff --git a/web/src/main/kotlin/world/phantasmal/web/application/widgets/MainContentWidget.kt b/web/src/main/kotlin/world/phantasmal/web/application/widgets/MainContentWidget.kt index f6c9e884..336f8202 100644 --- a/web/src/main/kotlin/world/phantasmal/web/application/widgets/MainContentWidget.kt +++ b/web/src/main/kotlin/world/phantasmal/web/application/widgets/MainContentWidget.kt @@ -1,6 +1,5 @@ package world.phantasmal.web.application.widgets -import kotlinx.coroutines.CoroutineScope import org.w3c.dom.Node import world.phantasmal.web.application.controllers.MainContentController import world.phantasmal.web.core.PwToolType @@ -9,17 +8,16 @@ import world.phantasmal.webui.widgets.LazyLoader import world.phantasmal.webui.widgets.Widget class MainContentWidget( - scope: CoroutineScope, private val ctrl: MainContentController, - private val toolViews: Map Widget>, -) : Widget(scope) { + private val toolViews: Map Widget>, +) : Widget() { override fun Node.createElement() = div { className = "pw-application-main-content" ctrl.tools.forEach { (tool, active) -> toolViews[tool]?.let { createWidget -> - addChild(LazyLoader(scope, visible = active, createWidget = createWidget)) + addChild(LazyLoader(visible = active, createWidget = createWidget)) } } } diff --git a/web/src/main/kotlin/world/phantasmal/web/application/widgets/NavigationWidget.kt b/web/src/main/kotlin/world/phantasmal/web/application/widgets/NavigationWidget.kt index c1195492..1e59dd7b 100644 --- a/web/src/main/kotlin/world/phantasmal/web/application/widgets/NavigationWidget.kt +++ b/web/src/main/kotlin/world/phantasmal/web/application/widgets/NavigationWidget.kt @@ -1,6 +1,5 @@ package world.phantasmal.web.application.widgets -import kotlinx.coroutines.CoroutineScope import org.w3c.dom.Node import world.phantasmal.observable.value.falseVal import world.phantasmal.observable.value.value @@ -13,16 +12,13 @@ import world.phantasmal.webui.dom.span import world.phantasmal.webui.widgets.Select import world.phantasmal.webui.widgets.Widget -class NavigationWidget( - scope: CoroutineScope, - private val ctrl: NavigationController, -) : Widget(scope) { +class NavigationWidget(private val ctrl: NavigationController) : Widget() { override fun Node.createElement() = div { className = "pw-application-navigation" ctrl.tools.forEach { (tool, active) -> - addChild(PwToolButton(scope, tool, active) { ctrl.setCurrentTool(tool) }) + addChild(PwToolButton(tool, active) { ctrl.setCurrentTool(tool) }) } div { @@ -32,7 +28,6 @@ class NavigationWidget( className = "pw-application-navigation-right" val serverSelect = Select( - scope, enabled = falseVal(), label = "Server:", items = listOf("Ephinea"), diff --git a/web/src/main/kotlin/world/phantasmal/web/application/widgets/PwToolButton.kt b/web/src/main/kotlin/world/phantasmal/web/application/widgets/PwToolButton.kt index a8053bb6..dda3f4c1 100644 --- a/web/src/main/kotlin/world/phantasmal/web/application/widgets/PwToolButton.kt +++ b/web/src/main/kotlin/world/phantasmal/web/application/widgets/PwToolButton.kt @@ -1,6 +1,5 @@ package world.phantasmal.web.application.widgets -import kotlinx.coroutines.CoroutineScope import org.w3c.dom.Node import world.phantasmal.observable.Observable import world.phantasmal.web.core.PwToolType @@ -10,11 +9,10 @@ import world.phantasmal.webui.dom.span import world.phantasmal.webui.widgets.Control class PwToolButton( - scope: CoroutineScope, private val tool: PwToolType, private val toggled: Observable, private val mouseDown: () -> Unit, -) : Control(scope) { +) : Control() { private val inputId = "pw-application-pw-tool-button-${tool.name.toLowerCase()}" override fun Node.createElement() = diff --git a/web/src/main/kotlin/world/phantasmal/web/core/PwTool.kt b/web/src/main/kotlin/world/phantasmal/web/core/PwTool.kt index 6597b723..3b89054c 100644 --- a/web/src/main/kotlin/world/phantasmal/web/core/PwTool.kt +++ b/web/src/main/kotlin/world/phantasmal/web/core/PwTool.kt @@ -1,6 +1,5 @@ package world.phantasmal.web.core -import kotlinx.coroutines.CoroutineScope import world.phantasmal.webui.widgets.Widget /** @@ -14,5 +13,5 @@ interface PwTool { /** * The caller of this method takes ownership of the returned widget. */ - fun initialize(scope: CoroutineScope): Widget + fun initialize(): Widget } diff --git a/web/src/main/kotlin/world/phantasmal/web/core/ThreeExtensions.kt b/web/src/main/kotlin/world/phantasmal/web/core/ThreeExtensions.kt index 44d0cd94..7a7c40c4 100644 --- a/web/src/main/kotlin/world/phantasmal/web/core/ThreeExtensions.kt +++ b/web/src/main/kotlin/world/phantasmal/web/core/ThreeExtensions.kt @@ -21,6 +21,10 @@ operator fun Vector3.minusAssign(other: Vector3) { operator fun Vector3.times(scalar: Double): Vector3 = clone().multiplyScalar(scalar) +operator fun Vector3.timesAssign(scalar: Double) { + multiplyScalar(scalar) +} + infix fun Vector3.dot(other: Vector3): Double = dot(other) diff --git a/web/src/main/kotlin/world/phantasmal/web/core/rendering/DisposableThreeRenderer.kt b/web/src/main/kotlin/world/phantasmal/web/core/rendering/DisposableThreeRenderer.kt new file mode 100644 index 00000000..a7e1cdb4 --- /dev/null +++ b/web/src/main/kotlin/world/phantasmal/web/core/rendering/DisposableThreeRenderer.kt @@ -0,0 +1,8 @@ +package world.phantasmal.web.core.rendering + +import world.phantasmal.core.disposable.Disposable +import world.phantasmal.web.externals.three.WebGLRenderer + +interface DisposableThreeRenderer : Disposable { + val renderer: WebGLRenderer +} diff --git a/web/src/main/kotlin/world/phantasmal/web/core/rendering/OrbitalCameraInputManager.kt b/web/src/main/kotlin/world/phantasmal/web/core/rendering/OrbitalCameraInputManager.kt index c59a7dc5..1771cc84 100644 --- a/web/src/main/kotlin/world/phantasmal/web/core/rendering/OrbitalCameraInputManager.kt +++ b/web/src/main/kotlin/world/phantasmal/web/core/rendering/OrbitalCameraInputManager.kt @@ -13,6 +13,7 @@ class OrbitalCameraInputManager( private val camera: Camera, position: Vector3, screenSpacePanning: Boolean, + enableRotate: Boolean = true, ) : TrackedDisposable(), InputManager { private val controls = OrbitControls(camera, canvas) @@ -31,6 +32,7 @@ class OrbitalCameraInputManager( camera.position.copy(position) controls.screenSpacePanning = screenSpacePanning + controls.enableRotate = enableRotate controls.update() controls.saveState() } diff --git a/web/src/main/kotlin/world/phantasmal/web/core/rendering/Renderer.kt b/web/src/main/kotlin/world/phantasmal/web/core/rendering/Renderer.kt index 923e7ce2..c4a2e837 100644 --- a/web/src/main/kotlin/world/phantasmal/web/core/rendering/Renderer.kt +++ b/web/src/main/kotlin/world/phantasmal/web/core/rendering/Renderer.kt @@ -4,17 +4,12 @@ import kotlinx.browser.document import kotlinx.browser.window import mu.KotlinLogging import org.w3c.dom.HTMLCanvasElement -import world.phantasmal.core.disposable.Disposable import world.phantasmal.webui.DisposableContainer import kotlin.math.floor import world.phantasmal.web.externals.three.Renderer as ThreeRenderer private val logger = KotlinLogging.logger {} -interface DisposableThreeRenderer : Disposable { - val renderer: ThreeRenderer -} - abstract class Renderer : DisposableContainer() { protected abstract val context: RenderContext protected abstract val threeRenderer: ThreeRenderer diff --git a/web/src/main/kotlin/world/phantasmal/web/core/rendering/conversion/XvrTextureConversion.kt b/web/src/main/kotlin/world/phantasmal/web/core/rendering/conversion/XvrTextureConversion.kt index 676d4cfd..e069860a 100644 --- a/web/src/main/kotlin/world/phantasmal/web/core/rendering/conversion/XvrTextureConversion.kt +++ b/web/src/main/kotlin/world/phantasmal/web/core/rendering/conversion/XvrTextureConversion.kt @@ -55,7 +55,7 @@ private fun xvrTextureToUint8Array(xvr: XvrTexture): Uint8Array { val stride = 4 * xvr.width var i = 0 - while (cursor.hasBytesLeft(8)) { + while (cursor.bytesLeft >= 8) { // Each block of 4 x 4 pixels is compressed to 8 bytes. val c0 = cursor.uShort().toInt() // Color 0 val c1 = cursor.uShort().toInt() // Color 1 diff --git a/web/src/main/kotlin/world/phantasmal/web/core/stores/UiStore.kt b/web/src/main/kotlin/world/phantasmal/web/core/stores/UiStore.kt index e65f7ddb..d93b10a7 100644 --- a/web/src/main/kotlin/world/phantasmal/web/core/stores/UiStore.kt +++ b/web/src/main/kotlin/world/phantasmal/web/core/stores/UiStore.kt @@ -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.observable.value.MutableVal import world.phantasmal.observable.value.Val @@ -20,10 +19,7 @@ interface ApplicationUrl { fun replaceUrl(url: String) } -class UiStore( - scope: CoroutineScope, - private val applicationUrl: ApplicationUrl, -) : Store(scope) { +class UiStore(private val applicationUrl: ApplicationUrl) : Store() { private val _currentTool: MutableVal private val _path = mutableVal("") @@ -82,7 +78,7 @@ class UiStore( .toMap() addDisposables( - disposableListener(window, "keydown", ::dispatchGlobalKeydown), + window.disposableListener("keydown", ::dispatchGlobalKeydown), ) observe(applicationUrl.url) { setDataFromUrl(it) } diff --git a/web/src/main/kotlin/world/phantasmal/web/core/widgets/DockWidget.kt b/web/src/main/kotlin/world/phantasmal/web/core/widgets/DockWidget.kt index 1170bbbd..6d98bb28 100644 --- a/web/src/main/kotlin/world/phantasmal/web/core/widgets/DockWidget.kt +++ b/web/src/main/kotlin/world/phantasmal/web/core/widgets/DockWidget.kt @@ -1,6 +1,5 @@ package world.phantasmal.web.core.widgets -import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch import org.w3c.dom.Node import world.phantasmal.observable.value.Val @@ -12,11 +11,10 @@ import world.phantasmal.webui.obj import world.phantasmal.webui.widgets.Widget class DockWidget( - scope: CoroutineScope, visible: Val = trueVal(), private val ctrl: DockController, - private val createWidget: (scope: CoroutineScope, id: String) -> Widget?, -) : Widget(scope, visible) { + private val createWidget: (id: String) -> Widget?, +) : Widget(visible) { private var goldenLayout: GoldenLayout? = null init { @@ -49,7 +47,7 @@ class DockWidget( goldenLayout.registerComponent(id) { container: GoldenLayout.Container -> val node = container.getElement()[0] as Node - createWidget(scope, id)?.let { widget -> + createWidget(id)?.let { widget -> node.addChild(widget) widget.focus() } diff --git a/web/src/main/kotlin/world/phantasmal/web/core/widgets/RendererWidget.kt b/web/src/main/kotlin/world/phantasmal/web/core/widgets/RendererWidget.kt index f6dcd55c..e64b4b86 100644 --- a/web/src/main/kotlin/world/phantasmal/web/core/widgets/RendererWidget.kt +++ b/web/src/main/kotlin/world/phantasmal/web/core/widgets/RendererWidget.kt @@ -1,15 +1,13 @@ package world.phantasmal.web.core.widgets -import kotlinx.coroutines.CoroutineScope import org.w3c.dom.Node import world.phantasmal.web.core.rendering.Renderer import world.phantasmal.webui.dom.div import world.phantasmal.webui.widgets.Widget class RendererWidget( - scope: CoroutineScope, private val renderer: Renderer, -) : Widget(scope) { +) : Widget() { override fun Node.createElement() = div { className = "pw-core-renderer" diff --git a/web/src/main/kotlin/world/phantasmal/web/core/widgets/UnavailableWidget.kt b/web/src/main/kotlin/world/phantasmal/web/core/widgets/UnavailableWidget.kt index 0b0d64d8..8fe1417f 100644 --- a/web/src/main/kotlin/world/phantasmal/web/core/widgets/UnavailableWidget.kt +++ b/web/src/main/kotlin/world/phantasmal/web/core/widgets/UnavailableWidget.kt @@ -1,6 +1,5 @@ package world.phantasmal.web.core.widgets -import kotlinx.coroutines.CoroutineScope import org.w3c.dom.Node import world.phantasmal.observable.value.Val import world.phantasmal.observable.value.falseVal @@ -9,15 +8,14 @@ import world.phantasmal.webui.widgets.Label import world.phantasmal.webui.widgets.Widget class UnavailableWidget( - scope: CoroutineScope, visible: Val, private val message: String, -) : Widget(scope, visible) { +) : Widget(visible) { override fun Node.createElement() = div { className = "pw-core-unavailable" - addWidget(Label(scope, enabled = falseVal(), text = message)) + addWidget(Label(enabled = falseVal(), text = message)) } companion object { diff --git a/web/src/main/kotlin/world/phantasmal/web/externals/three/OrbitControls.kt b/web/src/main/kotlin/world/phantasmal/web/externals/three/OrbitControls.kt index a05aec68..8c047227 100644 --- a/web/src/main/kotlin/world/phantasmal/web/externals/three/OrbitControls.kt +++ b/web/src/main/kotlin/world/phantasmal/web/externals/three/OrbitControls.kt @@ -1,6 +1,6 @@ @file:JsModule("three/examples/jsm/controls/OrbitControls") @file:JsNonModule -@file:Suppress("PropertyName") +@file:Suppress("PropertyName", "unused") package world.phantasmal.web.externals.three @@ -14,6 +14,9 @@ external interface OrbitControlsMouseButtons { external class OrbitControls(`object`: Camera, domElement: HTMLElement = definedExternally) { var enabled: Boolean + var enablePan: Boolean + var enableRotate: Boolean + var enableZoom: Boolean var target: Vector3 var zoomSpeed: Double var screenSpacePanning: Boolean diff --git a/web/src/main/kotlin/world/phantasmal/web/externals/three/three.kt b/web/src/main/kotlin/world/phantasmal/web/externals/three/three.kt index 260942e3..43856a28 100644 --- a/web/src/main/kotlin/world/phantasmal/web/externals/three/three.kt +++ b/web/src/main/kotlin/world/phantasmal/web/externals/three/three.kt @@ -18,6 +18,7 @@ external class Vector2(x: Double = definedExternally, y: Double = definedExterna * Sets value of this vector. */ fun set(x: Double, y: Double): Vector2 + fun clone(): Vector2 /** * Copies value of v to this vector. @@ -28,6 +29,8 @@ external class Vector2(x: Double = definedExternally, y: Double = definedExterna * Checks for strict equality of this vector and v. */ fun equals(v: Vector2): Boolean + + fun distanceTo(v: Vector2): Double } external class Vector3( @@ -172,6 +175,16 @@ external class Plane(normal: Vector3 = definedExternally, constant: Double = def fun projectPoint(point: Vector3, target: Vector3): Vector3 } +external class Box3(min: Vector3 = definedExternally, max: Vector3 = definedExternally) { + var min: Vector3 + var max: Vector3 +} + +external class Sphere(center: Vector3 = definedExternally, radius: Double = definedExternally) { + var center: Vector3 + var radius: Double +} + open external class EventDispatcher external interface Renderer { @@ -192,15 +205,23 @@ external interface WebGLRendererParameters { var antialias: Boolean } -external class WebGLRenderer(parameters: WebGLRendererParameters = definedExternally) : Renderer { +open external class WebGLRenderer( + parameters: WebGLRendererParameters = definedExternally, +) : Renderer { override val domElement: HTMLCanvasElement + var autoClearColor: Boolean + override fun render(scene: Object3D, camera: Camera) override fun setSize(width: Double, height: Double) fun setPixelRatio(value: Double) + fun setClearColor(color: Color, alpha: Double = definedExternally) + + fun clearColor() + fun dispose() } @@ -252,6 +273,9 @@ open external class Object3D { fun remove(vararg `object`: Object3D): Object3D fun clear(): Object3D + fun lookAt(vector: Vector3) + fun lookAt(x: Double, y: Double, z: Double) + /** * Updates local transform. */ @@ -479,6 +503,7 @@ external class PlaneGeometry( open external class BufferGeometry : EventDispatcher { var boundingBox: Box3? + var boundingSphere: Sphere? fun setIndex(index: BufferAttribute?) fun setIndex(index: Array?) @@ -656,11 +681,6 @@ external class CompressedTexture( encoding: TextureEncoding = definedExternally, ) : Texture -external class Box3(min: Vector3 = definedExternally, max: Vector3 = definedExternally) { - var min: Vector3 - var max: Vector3 -} - external enum class MOUSE { LEFT, MIDDLE, diff --git a/web/src/main/kotlin/world/phantasmal/web/huntOptimizer/HuntOptimizer.kt b/web/src/main/kotlin/world/phantasmal/web/huntOptimizer/HuntOptimizer.kt index b3557440..eb78682c 100644 --- a/web/src/main/kotlin/world/phantasmal/web/huntOptimizer/HuntOptimizer.kt +++ b/web/src/main/kotlin/world/phantasmal/web/huntOptimizer/HuntOptimizer.kt @@ -1,6 +1,5 @@ package world.phantasmal.web.huntOptimizer -import kotlinx.coroutines.CoroutineScope import world.phantasmal.web.core.PwTool import world.phantasmal.web.core.PwToolType import world.phantasmal.web.core.loading.AssetLoader @@ -19,9 +18,9 @@ class HuntOptimizer( ) : DisposableContainer(), PwTool { override val toolType = PwToolType.HuntOptimizer - override fun initialize(scope: CoroutineScope): Widget { + override fun initialize(): Widget { // Stores - val huntMethodStore = addDisposable(HuntMethodStore(scope, uiStore, assetLoader)) + val huntMethodStore = addDisposable(HuntMethodStore(uiStore, assetLoader)) // Controllers val huntOptimizerController = addDisposable(HuntOptimizerController(uiStore)) @@ -29,9 +28,8 @@ class HuntOptimizer( // Main Widget return HuntOptimizerWidget( - scope, ctrl = huntOptimizerController, - createMethodsWidget = { s -> MethodsWidget(s, methodsController) } + createMethodsWidget = { MethodsWidget(methodsController) } ) } } diff --git a/web/src/main/kotlin/world/phantasmal/web/huntOptimizer/stores/HuntMethodStore.kt b/web/src/main/kotlin/world/phantasmal/web/huntOptimizer/stores/HuntMethodStore.kt index 4c5bc927..ccba2b3e 100644 --- a/web/src/main/kotlin/world/phantasmal/web/huntOptimizer/stores/HuntMethodStore.kt +++ b/web/src/main/kotlin/world/phantasmal/web/huntOptimizer/stores/HuntMethodStore.kt @@ -1,6 +1,5 @@ package world.phantasmal.web.huntOptimizer.stores -import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import world.phantasmal.lib.fileFormats.quest.Episode @@ -22,10 +21,9 @@ import kotlin.collections.set import kotlin.time.minutes class HuntMethodStore( - scope: CoroutineScope, uiStore: UiStore, private val assetLoader: AssetLoader, -) : Store(scope) { +) : Store() { private val _methods = mutableListVal() val methods: ListVal by lazy { @@ -34,7 +32,7 @@ class HuntMethodStore( } private fun loadMethods(server: Server) { - launch(IoDispatcher) { + scope.launch(IoDispatcher) { val quests = assetLoader.load>("/quests.${server.slug}.json") val methods = quests diff --git a/web/src/main/kotlin/world/phantasmal/web/huntOptimizer/widgets/HelpWidget.kt b/web/src/main/kotlin/world/phantasmal/web/huntOptimizer/widgets/HelpWidget.kt index a8ef1265..574e9365 100644 --- a/web/src/main/kotlin/world/phantasmal/web/huntOptimizer/widgets/HelpWidget.kt +++ b/web/src/main/kotlin/world/phantasmal/web/huntOptimizer/widgets/HelpWidget.kt @@ -1,12 +1,11 @@ package world.phantasmal.web.huntOptimizer.widgets -import kotlinx.coroutines.CoroutineScope import org.w3c.dom.Node import world.phantasmal.webui.dom.div import world.phantasmal.webui.dom.p import world.phantasmal.webui.widgets.Widget -class HelpWidget(scope: CoroutineScope) : Widget(scope) { +class HelpWidget() : Widget() { override fun Node.createElement() = div { className = "pw-hunt-optimizer-help" diff --git a/web/src/main/kotlin/world/phantasmal/web/huntOptimizer/widgets/HuntOptimizerWidget.kt b/web/src/main/kotlin/world/phantasmal/web/huntOptimizer/widgets/HuntOptimizerWidget.kt index 1417f79d..fd8c9342 100644 --- a/web/src/main/kotlin/world/phantasmal/web/huntOptimizer/widgets/HuntOptimizerWidget.kt +++ b/web/src/main/kotlin/world/phantasmal/web/huntOptimizer/widgets/HuntOptimizerWidget.kt @@ -1,6 +1,5 @@ package world.phantasmal.web.huntOptimizer.widgets -import kotlinx.coroutines.CoroutineScope import org.w3c.dom.Node import world.phantasmal.web.huntOptimizer.HuntOptimizerUrls import world.phantasmal.web.huntOptimizer.controllers.HuntOptimizerController @@ -9,26 +8,24 @@ import world.phantasmal.webui.widgets.TabContainer import world.phantasmal.webui.widgets.Widget class HuntOptimizerWidget( - scope: CoroutineScope, private val ctrl: HuntOptimizerController, - private val createMethodsWidget: (CoroutineScope) -> MethodsWidget, -) : Widget(scope) { + private val createMethodsWidget: () -> MethodsWidget, +) : Widget() { override fun Node.createElement() = div { className = "pw-hunt-optimizer-hunt-optimizer" addChild(TabContainer( - scope, ctrl = ctrl, - createWidget = { scope, tab -> + createWidget = { tab -> when (tab.path) { - HuntOptimizerUrls.optimize -> object : Widget(scope) { + HuntOptimizerUrls.optimize -> object : Widget() { override fun Node.createElement() = div { textContent = "TODO" } } - HuntOptimizerUrls.methods -> createMethodsWidget(scope) - HuntOptimizerUrls.help -> HelpWidget(scope) + HuntOptimizerUrls.methods -> createMethodsWidget() + HuntOptimizerUrls.help -> HelpWidget() else -> error("""Unknown tab "${tab.title}".""") } } diff --git a/web/src/main/kotlin/world/phantasmal/web/huntOptimizer/widgets/MethodsForEpisodeWidget.kt b/web/src/main/kotlin/world/phantasmal/web/huntOptimizer/widgets/MethodsForEpisodeWidget.kt index 103c662c..7fa113c9 100644 --- a/web/src/main/kotlin/world/phantasmal/web/huntOptimizer/widgets/MethodsForEpisodeWidget.kt +++ b/web/src/main/kotlin/world/phantasmal/web/huntOptimizer/widgets/MethodsForEpisodeWidget.kt @@ -1,6 +1,5 @@ package world.phantasmal.web.huntOptimizer.widgets -import kotlinx.coroutines.CoroutineScope import org.w3c.dom.Node import world.phantasmal.lib.fileFormats.quest.Episode import world.phantasmal.web.huntOptimizer.controllers.MethodsController @@ -8,10 +7,9 @@ import world.phantasmal.webui.dom.div import world.phantasmal.webui.widgets.Widget class MethodsForEpisodeWidget( - scope: CoroutineScope, private val ctrl: MethodsController, private val episode: Episode, -) : Widget(scope) { +) : Widget() { override fun Node.createElement() = div { className = "pw-hunt-optimizer-methods-for-episode" diff --git a/web/src/main/kotlin/world/phantasmal/web/huntOptimizer/widgets/MethodsWidget.kt b/web/src/main/kotlin/world/phantasmal/web/huntOptimizer/widgets/MethodsWidget.kt index a4ffa13b..0acb0139 100644 --- a/web/src/main/kotlin/world/phantasmal/web/huntOptimizer/widgets/MethodsWidget.kt +++ b/web/src/main/kotlin/world/phantasmal/web/huntOptimizer/widgets/MethodsWidget.kt @@ -1,6 +1,5 @@ package world.phantasmal.web.huntOptimizer.widgets -import kotlinx.coroutines.CoroutineScope import org.w3c.dom.Node import world.phantasmal.web.huntOptimizer.controllers.MethodsController import world.phantasmal.webui.dom.div @@ -8,15 +7,14 @@ import world.phantasmal.webui.widgets.TabContainer import world.phantasmal.webui.widgets.Widget class MethodsWidget( - scope: CoroutineScope, private val ctrl: MethodsController, -) : Widget(scope) { +) : Widget() { override fun Node.createElement() = div { className = "pw-hunt-optimizer-methods" - addChild(TabContainer(scope, ctrl = ctrl, createWidget = { scope, tab -> - MethodsForEpisodeWidget(scope, ctrl, tab.episode) + addChild(TabContainer(ctrl = ctrl, createWidget = { tab -> + MethodsForEpisodeWidget(ctrl, tab.episode) })) } diff --git a/web/src/main/kotlin/world/phantasmal/web/questEditor/QuestEditor.kt b/web/src/main/kotlin/world/phantasmal/web/questEditor/QuestEditor.kt index 74820f5e..78fd38e4 100644 --- a/web/src/main/kotlin/world/phantasmal/web/questEditor/QuestEditor.kt +++ b/web/src/main/kotlin/world/phantasmal/web/questEditor/QuestEditor.kt @@ -1,6 +1,5 @@ package world.phantasmal.web.questEditor -import kotlinx.coroutines.CoroutineScope import org.w3c.dom.HTMLCanvasElement import world.phantasmal.web.core.PwTool import world.phantasmal.web.core.PwToolType @@ -12,6 +11,7 @@ import world.phantasmal.web.questEditor.loading.AreaAssetLoader import world.phantasmal.web.questEditor.loading.EntityAssetLoader import world.phantasmal.web.questEditor.loading.QuestLoader import world.phantasmal.web.questEditor.persistence.QuestEditorUiPersister +import world.phantasmal.web.questEditor.rendering.EntityImageRenderer import world.phantasmal.web.questEditor.rendering.QuestRenderer import world.phantasmal.web.questEditor.stores.AreaStore import world.phantasmal.web.questEditor.stores.AssemblyEditorStore @@ -27,19 +27,19 @@ class QuestEditor( ) : DisposableContainer(), PwTool { override val toolType = PwToolType.QuestEditor - override fun initialize(scope: CoroutineScope): Widget { + override fun initialize(): Widget { // Asset Loaders - val questLoader = addDisposable(QuestLoader(scope, assetLoader)) - val areaAssetLoader = addDisposable(AreaAssetLoader(scope, assetLoader)) - val entityAssetLoader = addDisposable(EntityAssetLoader(scope, assetLoader)) + val questLoader = addDisposable(QuestLoader(assetLoader)) + val areaAssetLoader = addDisposable(AreaAssetLoader(assetLoader)) + val entityAssetLoader = addDisposable(EntityAssetLoader(assetLoader)) // Persistence val questEditorUiPersister = QuestEditorUiPersister() // Stores - val areaStore = addDisposable(AreaStore(scope, areaAssetLoader)) - val questEditorStore = addDisposable(QuestEditorStore(scope, uiStore, areaStore)) - val assemblyEditorStore = addDisposable(AssemblyEditorStore(scope, questEditorStore)) + val areaStore = addDisposable(AreaStore(areaAssetLoader)) + val questEditorStore = addDisposable(QuestEditorStore(uiStore, areaStore)) + val assemblyEditorStore = addDisposable(AssemblyEditorStore(questEditorStore)) // Controllers val questEditorController = addDisposable(QuestEditorController(questEditorUiPersister)) @@ -58,25 +58,24 @@ class QuestEditor( // Rendering val renderer = addDisposable(QuestRenderer( - scope, areaAssetLoader, entityAssetLoader, questEditorStore, createThreeRenderer, )) + val entityImageRenderer = EntityImageRenderer(entityAssetLoader, createThreeRenderer) // Main Widget return QuestEditorWidget( - scope, questEditorController, - { s -> QuestEditorToolbarWidget(s, toolbarController) }, - { s -> QuestInfoWidget(s, questInfoController) }, - { s -> NpcCountsWidget(s, npcCountsController) }, - { s -> EntityInfoWidget(s, entityInfoController) }, - { s -> QuestEditorRendererWidget(s, renderer) }, - { s -> AssemblyEditorWidget(s, assemblyEditorController) }, - { s -> EntityListWidget(s, npcListController) }, - { s -> EntityListWidget(s, objectListController) }, + { QuestEditorToolbarWidget(toolbarController) }, + { QuestInfoWidget(questInfoController) }, + { NpcCountsWidget(npcCountsController) }, + { EntityInfoWidget(entityInfoController) }, + { QuestEditorRendererWidget(renderer) }, + { AssemblyEditorWidget(assemblyEditorController) }, + { EntityListWidget(npcListController, entityImageRenderer) }, + { EntityListWidget(objectListController, entityImageRenderer) }, ) } } diff --git a/web/src/main/kotlin/world/phantasmal/web/questEditor/actions/CreateEntityAction.kt b/web/src/main/kotlin/world/phantasmal/web/questEditor/actions/CreateEntityAction.kt new file mode 100644 index 00000000..b415cf1a --- /dev/null +++ b/web/src/main/kotlin/world/phantasmal/web/questEditor/actions/CreateEntityAction.kt @@ -0,0 +1,22 @@ +package world.phantasmal.web.questEditor.actions + +import world.phantasmal.web.core.actions.Action +import world.phantasmal.web.questEditor.models.QuestEntityModel +import world.phantasmal.web.questEditor.models.QuestModel + +class CreateEntityAction( + private val setSelectedEntity: (QuestEntityModel<*, *>) -> Unit, + private val quest: QuestModel, + private val entity: QuestEntityModel<*, *>, +) : Action { + override val description: String = "Create ${entity.type.name}" + + override fun execute() { + quest.addEntity(entity) + setSelectedEntity(entity) + } + + override fun undo() { + quest.removeEntity(entity) + } +} diff --git a/web/src/main/kotlin/world/phantasmal/web/questEditor/loading/AreaAssetLoader.kt b/web/src/main/kotlin/world/phantasmal/web/questEditor/loading/AreaAssetLoader.kt index 5d885f0b..6e36b713 100644 --- a/web/src/main/kotlin/world/phantasmal/web/questEditor/loading/AreaAssetLoader.kt +++ b/web/src/main/kotlin/world/phantasmal/web/questEditor/loading/AreaAssetLoader.kt @@ -1,6 +1,5 @@ package world.phantasmal.web.questEditor.loading -import kotlinx.coroutines.CoroutineScope import org.khronos.webgl.ArrayBuffer import world.phantasmal.lib.Endianness import world.phantasmal.lib.cursor.cursor @@ -24,17 +23,13 @@ import world.phantasmal.webui.obj /** * Loads and caches area assets. */ -class AreaAssetLoader( - scope: CoroutineScope, - private val assetLoader: AssetLoader, -) : DisposableContainer() { +class AreaAssetLoader(private val assetLoader: AssetLoader) : DisposableContainer() { /** * This cache's values consist of an Object3D containing the area render meshes and a list of * that area's sections. */ private val renderObjectCache = addDisposable( LoadingCache>>( - scope, { (episode, areaVariant) -> val buffer = getAreaAsset(episode, areaVariant, AssetType.Render) val obj = parseAreaGeometry(buffer.cursor(Endianness.Little)) @@ -46,7 +41,6 @@ class AreaAssetLoader( private val collisionObjectCache = addDisposable( LoadingCache( - scope, { (episode, areaVariant) -> val buffer = getAreaAsset(episode, areaVariant, AssetType.Collision) val obj = parseAreaCollisionGeometry(buffer.cursor(Endianness.Little)) diff --git a/web/src/main/kotlin/world/phantasmal/web/questEditor/loading/EntityAssetLoader.kt b/web/src/main/kotlin/world/phantasmal/web/questEditor/loading/EntityAssetLoader.kt index 6ab3f98a..0b56d698 100644 --- a/web/src/main/kotlin/world/phantasmal/web/questEditor/loading/EntityAssetLoader.kt +++ b/web/src/main/kotlin/world/phantasmal/web/questEditor/loading/EntityAssetLoader.kt @@ -1,6 +1,5 @@ package world.phantasmal.web.questEditor.loading -import kotlinx.coroutines.CoroutineScope import mu.KotlinLogging import org.khronos.webgl.ArrayBuffer import world.phantasmal.core.PwResult @@ -22,13 +21,9 @@ import world.phantasmal.webui.DisposableContainer private val logger = KotlinLogging.logger {} -class EntityAssetLoader( - scope: CoroutineScope, - private val assetLoader: AssetLoader, -) : DisposableContainer() { +class EntityAssetLoader(private val assetLoader: AssetLoader) : DisposableContainer() { private val instancedMeshCache = addDisposable( LoadingCache, InstancedMesh>( - scope, { (type, model) -> try { loadMesh(type, model) ?: DEFAULT_MESH @@ -139,7 +134,10 @@ class EntityAssetLoader( }, MeshLambertMaterial(), count = 1000, - ) + ).apply { + // Start with 0 instances. + count = 0 + } } } diff --git a/web/src/main/kotlin/world/phantasmal/web/questEditor/loading/LoadingCache.kt b/web/src/main/kotlin/world/phantasmal/web/questEditor/loading/LoadingCache.kt index 947dcbba..b57f34c8 100644 --- a/web/src/main/kotlin/world/phantasmal/web/questEditor/loading/LoadingCache.kt +++ b/web/src/main/kotlin/world/phantasmal/web/questEditor/loading/LoadingCache.kt @@ -1,17 +1,14 @@ package world.phantasmal.web.questEditor.loading -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Deferred -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.async +import kotlinx.coroutines.* import world.phantasmal.core.disposable.TrackedDisposable @OptIn(ExperimentalCoroutinesApi::class) class LoadingCache( - private val scope: CoroutineScope, private val loadValue: suspend (K) -> V, private val disposeValue: (V) -> Unit, ) : TrackedDisposable() { + private val scope: CoroutineScope = CoroutineScope(Dispatchers.Default) private val map = mutableMapOf>() val values: Collection> = map.values @@ -31,6 +28,7 @@ class LoadingCache( } } + scope.cancel("LoadingCache disposed.") super.internalDispose() } } diff --git a/web/src/main/kotlin/world/phantasmal/web/questEditor/loading/QuestLoader.kt b/web/src/main/kotlin/world/phantasmal/web/questEditor/loading/QuestLoader.kt index f5e06aff..65c4c882 100644 --- a/web/src/main/kotlin/world/phantasmal/web/questEditor/loading/QuestLoader.kt +++ b/web/src/main/kotlin/world/phantasmal/web/questEditor/loading/QuestLoader.kt @@ -1,6 +1,5 @@ package world.phantasmal.web.questEditor.loading -import kotlinx.coroutines.CoroutineScope import org.khronos.webgl.ArrayBuffer import world.phantasmal.lib.Endianness import world.phantasmal.lib.cursor.cursor @@ -10,13 +9,9 @@ import world.phantasmal.lib.fileFormats.quest.parseQstToQuest import world.phantasmal.web.core.loading.AssetLoader import world.phantasmal.webui.DisposableContainer -class QuestLoader( - scope: CoroutineScope, - private val assetLoader: AssetLoader, -) : DisposableContainer() { +class QuestLoader(private val assetLoader: AssetLoader) : DisposableContainer() { private val cache = addDisposable( LoadingCache( - scope, { path -> assetLoader.loadArrayBuffer("/quests$path") }, { /* Nothing to dispose. */ } ) diff --git a/web/src/main/kotlin/world/phantasmal/web/questEditor/models/QuestModel.kt b/web/src/main/kotlin/world/phantasmal/web/questEditor/models/QuestModel.kt index 14ac320b..90e1a03f 100644 --- a/web/src/main/kotlin/world/phantasmal/web/questEditor/models/QuestModel.kt +++ b/web/src/main/kotlin/world/phantasmal/web/questEditor/models/QuestModel.kt @@ -132,4 +132,26 @@ class QuestModel( _longDescription.value = longDescription return this } + + fun addEntity(entity: QuestEntityModel<*, *>) { + when (entity) { + is QuestNpcModel -> addNpc(entity) + is QuestObjectModel -> addObject(entity) + } + } + + fun addNpc(npc: QuestNpcModel) { + _npcs.add(npc) + } + + fun addObject(obj: QuestObjectModel) { + _objects.add(obj) + } + + fun removeEntity(entity: QuestEntityModel<*, *>) { + when (entity) { + is QuestNpcModel -> _npcs.remove(entity) + is QuestObjectModel -> _objects.remove(entity) + } + } } diff --git a/web/src/main/kotlin/world/phantasmal/web/questEditor/rendering/EntityImageRenderer.kt b/web/src/main/kotlin/world/phantasmal/web/questEditor/rendering/EntityImageRenderer.kt new file mode 100644 index 00000000..3ab3ce98 --- /dev/null +++ b/web/src/main/kotlin/world/phantasmal/web/questEditor/rendering/EntityImageRenderer.kt @@ -0,0 +1,87 @@ +package world.phantasmal.web.questEditor.rendering + +import kotlinx.browser.document +import org.w3c.dom.HTMLCanvasElement +import org.w3c.dom.url.URL +import world.phantasmal.core.math.degToRad +import world.phantasmal.lib.fileFormats.quest.EntityType +import world.phantasmal.web.core.rendering.DisposableThreeRenderer +import world.phantasmal.web.core.rendering.disposeObject3DResources +import world.phantasmal.web.core.timesAssign +import world.phantasmal.web.externals.three.* +import world.phantasmal.web.questEditor.loading.EntityAssetLoader +import world.phantasmal.web.questEditor.loading.LoadingCache +import world.phantasmal.webui.DisposableContainer +import world.phantasmal.webui.obj +import kotlin.math.tan + +class EntityImageRenderer( + private val entityAssetLoader: EntityAssetLoader, + createThreeRenderer: (HTMLCanvasElement) -> DisposableThreeRenderer, +) : DisposableContainer() { + private val threeRenderer = addDisposable( + createThreeRenderer(document.createElement("CANVAS") as HTMLCanvasElement) + ).renderer.apply { + setClearColor(Color(0x000000), alpha = 0.0) + autoClearColor = false + setSize(100.0, 100.0) + } + + private val cache: LoadingCache = addDisposable( + LoadingCache(::renderToDataUrl) { URL.revokeObjectURL(it) } + ) + + private val scene = Scene() + + private val light = HemisphereLight(0xffffff, 0x505050, 1.2) + private val camera = PerspectiveCamera(fov = 30.0, aspect = 1.0, near = 10.0, far = 2000.0) + private val cameraPos = Vector3(1.0, 1.0, 2.0).normalize() + private val cameraDistFactor = 1.3 / tan(degToRad(camera.fov) / 2) + + suspend fun renderToImage(type: EntityType): String = cache.get(type) + + private suspend fun renderToDataUrl(type: EntityType): String { + // First render a flat version of the model with the same color as the background. Then + // render the final version on top of that. We do this to somewhat fix issues with + // additive alpha blending on a transparent background. + + val mesh = entityAssetLoader.loadInstancedMesh(type, model = null) + val origMaterial = mesh.material + + try { + mesh.count = 1 + mesh.setMatrixAt(0, Matrix4()) + scene.clear() + scene.add(light, mesh) + + // Compute camera position. + val bSphere = (mesh.geometry as BufferGeometry).boundingSphere!! + camera.position.copy(cameraPos) + camera.position *= bSphere.radius * cameraDistFactor + camera.lookAt(bSphere.center) + + // Render the flat model. + mesh.material = BACKGROUND_MATERIAL + threeRenderer.clearColor() + threeRenderer.render(scene, camera) + + // Render the textured model. + mesh.material = origMaterial + threeRenderer.render(scene, camera) + + threeRenderer.render(scene, camera) + return threeRenderer.domElement.toDataURL() + } finally { + // Ensure we dispose the original material and not the background material. + mesh.material = origMaterial + disposeObject3DResources(mesh) + } + } + + companion object { + private val BACKGROUND_MATERIAL = MeshBasicMaterial(obj { + color = Color(0x262626) + side = DoubleSide + }) + } +} diff --git a/web/src/main/kotlin/world/phantasmal/web/questEditor/rendering/EntityInstancedMesh.kt b/web/src/main/kotlin/world/phantasmal/web/questEditor/rendering/EntityInstancedMesh.kt index f846e3f1..20894513 100644 --- a/web/src/main/kotlin/world/phantasmal/web/questEditor/rendering/EntityInstancedMesh.kt +++ b/web/src/main/kotlin/world/phantasmal/web/questEditor/rendering/EntityInstancedMesh.kt @@ -39,7 +39,7 @@ class EntityInstancedMesh( entity, mesh, instanceIndex, - selectedWave + selectedWave, ) { index -> removeAt(index) modelChanged(entity) diff --git a/web/src/main/kotlin/world/phantasmal/web/questEditor/rendering/EntityMeshManager.kt b/web/src/main/kotlin/world/phantasmal/web/questEditor/rendering/EntityMeshManager.kt index 599d657f..45d6a8b2 100644 --- a/web/src/main/kotlin/world/phantasmal/web/questEditor/rendering/EntityMeshManager.kt +++ b/web/src/main/kotlin/world/phantasmal/web/questEditor/rendering/EntityMeshManager.kt @@ -15,17 +15,17 @@ import world.phantasmal.webui.DisposableContainer private val logger = KotlinLogging.logger {} class EntityMeshManager( - private val scope: CoroutineScope, private val questEditorStore: QuestEditorStore, private val renderContext: QuestRenderContext, private val entityAssetLoader: EntityAssetLoader, ) : DisposableContainer() { + private val scope: CoroutineScope = CoroutineScope(Dispatchers.Main) + /** * Contains one [EntityInstancedMesh] per [EntityType] and model. */ private val entityMeshCache = addDisposable( LoadingCache( - scope, { (type, model) -> val mesh = entityAssetLoader.loadInstancedMesh(type, model) renderContext.entities.add(mesh) @@ -87,13 +87,12 @@ class EntityMeshManager( loadingEntities.getOrPut(entity) { scope.launch { try { - val meshContainer = entityMeshCache.get(TypeAndModel( + val entityInstancedMesh = entityMeshCache.get(TypeAndModel( type = entity.type, model = (entity as? QuestObjectModel)?.model?.value )) - val instance = meshContainer.addInstance(entity) - loadingEntities.remove(entity) + val instance = entityInstancedMesh.addInstance(entity) if (entity == questEditorStore.selectedEntity.value) { markSelected(instance) @@ -103,10 +102,11 @@ class EntityMeshManager( } catch (e: CancellationException) { // Do nothing. } catch (e: Throwable) { - loadingEntities.remove(entity) logger.error(e) { "Couldn't load mesh for entity of type ${entity.type}." } + } finally { + loadingEntities.remove(entity) } } } diff --git a/web/src/main/kotlin/world/phantasmal/web/questEditor/rendering/QuestEditorMeshManager.kt b/web/src/main/kotlin/world/phantasmal/web/questEditor/rendering/QuestEditorMeshManager.kt index 2351871d..10c99266 100644 --- a/web/src/main/kotlin/world/phantasmal/web/questEditor/rendering/QuestEditorMeshManager.kt +++ b/web/src/main/kotlin/world/phantasmal/web/questEditor/rendering/QuestEditorMeshManager.kt @@ -1,6 +1,5 @@ package world.phantasmal.web.questEditor.rendering -import kotlinx.coroutines.CoroutineScope import world.phantasmal.lib.fileFormats.quest.Episode import world.phantasmal.observable.value.list.ListVal import world.phantasmal.observable.value.list.listVal @@ -11,12 +10,11 @@ import world.phantasmal.web.questEditor.models.* import world.phantasmal.web.questEditor.stores.QuestEditorStore class QuestEditorMeshManager( - scope: CoroutineScope, areaAssetLoader: AreaAssetLoader, entityAssetLoader: EntityAssetLoader, questEditorStore: QuestEditorStore, renderContext: QuestRenderContext, -) : QuestMeshManager(scope, areaAssetLoader, entityAssetLoader, questEditorStore, renderContext) { +) : QuestMeshManager(areaAssetLoader, entityAssetLoader, questEditorStore, renderContext) { init { addDisposables( map( diff --git a/web/src/main/kotlin/world/phantasmal/web/questEditor/rendering/QuestMeshManager.kt b/web/src/main/kotlin/world/phantasmal/web/questEditor/rendering/QuestMeshManager.kt index c6dca864..be5dc1a0 100644 --- a/web/src/main/kotlin/world/phantasmal/web/questEditor/rendering/QuestMeshManager.kt +++ b/web/src/main/kotlin/world/phantasmal/web/questEditor/rendering/QuestMeshManager.kt @@ -1,6 +1,7 @@ package world.phantasmal.web.questEditor.rendering import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.launch import world.phantasmal.core.disposable.Disposer @@ -19,16 +20,16 @@ import world.phantasmal.webui.DisposableContainer * Loads the necessary area and entity 3D models into [QuestRenderer]. */ abstract class QuestMeshManager protected constructor( - private val scope: CoroutineScope, areaAssetLoader: AreaAssetLoader, entityAssetLoader: EntityAssetLoader, questEditorStore: QuestEditorStore, renderContext: QuestRenderContext, ) : DisposableContainer() { + private val scope: CoroutineScope = CoroutineScope(Dispatchers.Default) private val areaDisposer = addDisposable(Disposer()) private val areaMeshManager = AreaMeshManager(renderContext, areaAssetLoader) private val entityMeshManager = addDisposable( - EntityMeshManager(scope, questEditorStore, renderContext, entityAssetLoader) + EntityMeshManager(questEditorStore, renderContext, entityAssetLoader) ) private var loadJob: Job? = null diff --git a/web/src/main/kotlin/world/phantasmal/web/questEditor/rendering/QuestRenderer.kt b/web/src/main/kotlin/world/phantasmal/web/questEditor/rendering/QuestRenderer.kt index 70f008ca..4e4f440e 100644 --- a/web/src/main/kotlin/world/phantasmal/web/questEditor/rendering/QuestRenderer.kt +++ b/web/src/main/kotlin/world/phantasmal/web/questEditor/rendering/QuestRenderer.kt @@ -11,7 +11,6 @@ import world.phantasmal.web.questEditor.rendering.input.QuestInputManager import world.phantasmal.web.questEditor.stores.QuestEditorStore class QuestRenderer( - scope: CoroutineScope, areaAssetLoader: AreaAssetLoader, entityAssetLoader: EntityAssetLoader, questEditorStore: QuestEditorStore, @@ -34,7 +33,6 @@ class QuestRenderer( init { addDisposables( QuestEditorMeshManager( - scope, areaAssetLoader, entityAssetLoader, questEditorStore, diff --git a/web/src/main/kotlin/world/phantasmal/web/questEditor/rendering/input/Evt.kt b/web/src/main/kotlin/world/phantasmal/web/questEditor/rendering/input/Evt.kt index 692f821d..dde9ab35 100644 --- a/web/src/main/kotlin/world/phantasmal/web/questEditor/rendering/input/Evt.kt +++ b/web/src/main/kotlin/world/phantasmal/web/questEditor/rendering/input/Evt.kt @@ -1,37 +1,82 @@ package world.phantasmal.web.questEditor.rendering.input +import world.phantasmal.lib.fileFormats.quest.EntityType import world.phantasmal.web.externals.three.Vector2 +import world.phantasmal.web.questEditor.widgets.EntityDragEvent sealed class Evt sealed class PointerEvt : Evt() { abstract val buttons: Int abstract val shiftKeyDown: Boolean - abstract val movedSinceLastPointerDown: Boolean /** * Pointer position in normalized device space. */ abstract val pointerDevicePosition: Vector2 + abstract val movedSinceLastPointerDown: Boolean } class PointerDownEvt( override val buttons: Int, override val shiftKeyDown: Boolean, - override val movedSinceLastPointerDown: Boolean, override val pointerDevicePosition: Vector2, + override val movedSinceLastPointerDown: Boolean, ) : PointerEvt() class PointerUpEvt( override val buttons: Int, override val shiftKeyDown: Boolean, - override val movedSinceLastPointerDown: Boolean, override val pointerDevicePosition: Vector2, + override val movedSinceLastPointerDown: Boolean, ) : PointerEvt() class PointerMoveEvt( override val buttons: Int, override val shiftKeyDown: Boolean, - override val movedSinceLastPointerDown: Boolean, override val pointerDevicePosition: Vector2, + override val movedSinceLastPointerDown: Boolean, ) : PointerEvt() + +sealed class EntityDragEvt( + private val event: EntityDragEvent, + /** + * Pointer position in normalized device space. + */ + val pointerDevicePosition: Vector2, +) : Evt() { + val entityType: EntityType = event.entityType + val shiftKeyDown: Boolean = event.shiftKeyDown + + fun allowDrop() { + event.allowDrop() + } + + fun showDragElement() { + event.showDragElement() + } + + fun hideDragElement() { + event.hideDragElement() + } +} + +class EntityDragEnterEvt( + event: EntityDragEvent, + pointerDevicePosition: Vector2, +) : EntityDragEvt(event, pointerDevicePosition) + +class EntityDragOverEvt( + event: EntityDragEvent, + pointerDevicePosition: Vector2, +) : EntityDragEvt(event, pointerDevicePosition) + +class EntityDragLeaveEvt( + event: EntityDragEvent, + pointerDevicePosition: Vector2, +) : EntityDragEvt(event, pointerDevicePosition) + +class EntityDropEvt( + event: EntityDragEvent, + pointerDevicePosition: Vector2, +) : EntityDragEvt(event, pointerDevicePosition) diff --git a/web/src/main/kotlin/world/phantasmal/web/questEditor/rendering/input/QuestInputManager.kt b/web/src/main/kotlin/world/phantasmal/web/questEditor/rendering/input/QuestInputManager.kt index 3e38f94d..a454bd40 100644 --- a/web/src/main/kotlin/world/phantasmal/web/questEditor/rendering/input/QuestInputManager.kt +++ b/web/src/main/kotlin/world/phantasmal/web/questEditor/rendering/input/QuestInputManager.kt @@ -12,6 +12,7 @@ import world.phantasmal.web.questEditor.rendering.input.state.IdleState import world.phantasmal.web.questEditor.rendering.input.state.State import world.phantasmal.web.questEditor.rendering.input.state.StateContext import world.phantasmal.web.questEditor.stores.QuestEditorStore +import world.phantasmal.web.questEditor.widgets.* import world.phantasmal.webui.DisposableContainer import world.phantasmal.webui.dom.disposableListener @@ -42,11 +43,18 @@ class QuestInputManager( init { addDisposables( - disposableListener(renderContext.canvas, "pointerdown", ::onPointerDown) + renderContext.canvas.disposableListener("pointerdown", ::onPointerDown) ) onPointerMoveListener = - disposableListener(renderContext.canvas, "pointermove", ::onPointerMove) + renderContext.canvas.disposableListener("pointermove", ::onPointerMove) + + addDisposables( + renderContext.canvas.observeEntityDragEnter(::onEntityDragEnter), + renderContext.canvas.observeEntityDragOver(::onEntityDragOver), + renderContext.canvas.observeEntityDragLeave(::onEntityDragLeave), + renderContext.canvas.observeEntityDrop(::onEntityDrop), + ) // Ensure OrbitalCameraControls attaches its listeners after ours. cameraInputManager = OrbitalCameraInputManager( @@ -90,16 +98,16 @@ class QuestInputManager( PointerDownEvt( e.buttons.toInt(), shiftKeyDown = e.shiftKey, - movedSinceLastPointerDown, pointerDevicePosition, + movedSinceLastPointerDown, ) ) - onPointerUpListener = disposableListener(window, "pointerup", ::onPointerUp) + onPointerUpListener = window.disposableListener("pointerup", ::onPointerUp) // Stop listening to canvas move events and start listening to window move events. onPointerMoveListener?.dispose() - onPointerMoveListener = disposableListener(window, "pointermove", ::onPointerMove) + onPointerMoveListener = window.disposableListener("pointermove", ::onPointerMove) } private fun onPointerUp(e: PointerEvent) { @@ -110,8 +118,8 @@ class QuestInputManager( PointerUpEvt( e.buttons.toInt(), shiftKeyDown = e.shiftKey, - movedSinceLastPointerDown, pointerDevicePosition, + movedSinceLastPointerDown, ) ) } finally { @@ -121,7 +129,7 @@ class QuestInputManager( // Stop listening to window move events and start listening to canvas move events again. onPointerMoveListener?.dispose() onPointerMoveListener = - disposableListener(renderContext.canvas, "pointermove", ::onPointerMove) + renderContext.canvas.disposableListener("pointermove", ::onPointerMove) } } @@ -132,19 +140,47 @@ class QuestInputManager( PointerMoveEvt( e.buttons.toInt(), shiftKeyDown = e.shiftKey, - movedSinceLastPointerDown, pointerDevicePosition, + movedSinceLastPointerDown, ) ) } + private fun onEntityDragEnter(e: EntityDragEvent) { + processPointerEvent(type = null, e.clientX, e.clientY) + + state = state.processEvent(EntityDragEnterEvt(e, pointerDevicePosition)) + } + + private fun onEntityDragOver(e: EntityDragEvent) { + processPointerEvent(type = null, e.clientX, e.clientY) + + state = state.processEvent(EntityDragOverEvt(e, pointerDevicePosition)) + } + + private fun onEntityDragLeave(e: EntityDragEvent) { + processPointerEvent(type = null, e.clientX, e.clientY) + + state = state.processEvent(EntityDragLeaveEvt(e, pointerDevicePosition)) + } + + private fun onEntityDrop(e: EntityDragEvent) { + processPointerEvent(type = null, e.clientX, e.clientY) + + state = state.processEvent(EntityDropEvt(e, pointerDevicePosition)) + } + private fun processPointerEvent(e: PointerEvent) { + processPointerEvent(e.type, e.clientX, e.clientY) + } + + private fun processPointerEvent(type: String?, clientX: Int, clientY: Int) { val rect = renderContext.canvas.getBoundingClientRect() - pointerPosition.set(e.clientX - rect.left, e.clientY - rect.top) + pointerPosition.set(clientX - rect.left, clientY - rect.top) pointerDevicePosition.copy(pointerPosition) renderContext.pointerPosToDeviceCoords(pointerDevicePosition) - when (e.type) { + when (type) { "pointerdown" -> { movedSinceLastPointerDown = false } diff --git a/web/src/main/kotlin/world/phantasmal/web/questEditor/rendering/input/state/CreationState.kt b/web/src/main/kotlin/world/phantasmal/web/questEditor/rendering/input/state/CreationState.kt new file mode 100644 index 00000000..af4bd900 --- /dev/null +++ b/web/src/main/kotlin/world/phantasmal/web/questEditor/rendering/input/state/CreationState.kt @@ -0,0 +1,110 @@ +package world.phantasmal.web.questEditor.rendering.input.state + +import world.phantasmal.lib.fileFormats.quest.NpcType +import world.phantasmal.lib.fileFormats.quest.ObjectType +import world.phantasmal.lib.fileFormats.quest.QuestNpc +import world.phantasmal.lib.fileFormats.quest.QuestObject +import world.phantasmal.web.externals.three.Vector2 +import world.phantasmal.web.externals.three.Vector3 +import world.phantasmal.web.questEditor.models.* +import world.phantasmal.web.questEditor.rendering.input.* + +class CreationState( + private val ctx: StateContext, + event: EntityDragEnterEvt, + private val quest: QuestModel, + area: AreaModel, +) : State() { + private val entity: QuestEntityModel<*, *> = + when (event.entityType) { + is NpcType -> { + val wave = ctx.wave.value + QuestNpcModel( + QuestNpc(event.entityType, quest.episode, area.id, wave?.id?.value ?: 0), + wave, + ).also { + quest.addNpc(it) + } + } + is ObjectType -> { + QuestObjectModel( + QuestObject(event.entityType, area.id) + ).also { + quest.addObject(it) + } + } + else -> error("Unsupported entity type ${event.entityType::class}.") + } + + private val pointerDevicePosition = Vector2() + private var shouldTranslate = false + private var shouldTranslateVertically = false + + init { + event.allowDrop() + event.hideDragElement() + + ctx.translateEntityHorizontally( + entity, + ZERO_VECTOR, + ZERO_VECTOR, + event.pointerDevicePosition, + ) + + ctx.setSelectedEntity(entity) + } + + override fun processEvent(event: Evt): State = + when (event) { + is EntityDragOverEvt -> { + event.allowDrop() + pointerDevicePosition.copy(event.pointerDevicePosition) + shouldTranslate = true + shouldTranslateVertically = event.shiftKeyDown + this + } + + is EntityDragLeaveEvt -> { + event.showDragElement() + quest.removeEntity(entity) + IdleState(ctx, entityManipulationEnabled = true) + } + + is EntityDropEvt -> { + ctx.finalizeEntityCreation(quest, entity) + IdleState(ctx, entityManipulationEnabled = true) + } + + else -> this + } + + override fun beforeRender() { + if (shouldTranslate) { + if (shouldTranslateVertically) { + ctx.translateEntityVertically( + entity, + ZERO_VECTOR, + ZERO_VECTOR, + pointerDevicePosition, + ) + } else { + ctx.translateEntityHorizontally( + entity, + ZERO_VECTOR, + ZERO_VECTOR, + pointerDevicePosition, + ) + } + + shouldTranslate = false + } + } + + override fun cancel() { + quest.removeEntity(entity) + } + + companion object { + private val ZERO_VECTOR = Vector3(.0, .0, .0) + } +} diff --git a/web/src/main/kotlin/world/phantasmal/web/questEditor/rendering/input/state/IdleState.kt b/web/src/main/kotlin/world/phantasmal/web/questEditor/rendering/input/state/IdleState.kt index 127b3251..4214f8aa 100644 --- a/web/src/main/kotlin/world/phantasmal/web/questEditor/rendering/input/state/IdleState.kt +++ b/web/src/main/kotlin/world/phantasmal/web/questEditor/rendering/input/state/IdleState.kt @@ -5,10 +5,7 @@ import world.phantasmal.web.externals.three.Vector2 import world.phantasmal.web.externals.three.Vector3 import world.phantasmal.web.questEditor.models.QuestEntityModel import world.phantasmal.web.questEditor.rendering.EntityInstancedMesh -import world.phantasmal.web.questEditor.rendering.input.Evt -import world.phantasmal.web.questEditor.rendering.input.PointerDownEvt -import world.phantasmal.web.questEditor.rendering.input.PointerMoveEvt -import world.phantasmal.web.questEditor.rendering.input.PointerUpEvt +import world.phantasmal.web.questEditor.rendering.input.* class IdleState( private val ctx: StateContext, @@ -87,6 +84,17 @@ class IdleState( shouldCheckHighlight = true } } + + is EntityDragEnterEvt -> { + val quest = ctx.quest.value + val area = ctx.area.value + + if (quest != null && area != null) { + return CreationState(ctx, event, quest, area) + } + } + + else -> return this } return this diff --git a/web/src/main/kotlin/world/phantasmal/web/questEditor/rendering/input/state/StateContext.kt b/web/src/main/kotlin/world/phantasmal/web/questEditor/rendering/input/state/StateContext.kt index f03eb0c3..c0b9a0b3 100644 --- a/web/src/main/kotlin/world/phantasmal/web/questEditor/rendering/input/state/StateContext.kt +++ b/web/src/main/kotlin/world/phantasmal/web/questEditor/rendering/input/state/StateContext.kt @@ -1,14 +1,14 @@ package world.phantasmal.web.questEditor.rendering.input.state +import world.phantasmal.observable.value.Val import world.phantasmal.web.core.minusAssign import world.phantasmal.web.core.plusAssign import world.phantasmal.web.core.rendering.OrbitalCameraInputManager -import world.phantasmal.web.core.toQuaternion import world.phantasmal.web.externals.three.* +import world.phantasmal.web.questEditor.actions.CreateEntityAction import world.phantasmal.web.questEditor.actions.RotateEntityAction import world.phantasmal.web.questEditor.actions.TranslateEntityAction -import world.phantasmal.web.questEditor.models.QuestEntityModel -import world.phantasmal.web.questEditor.models.SectionModel +import world.phantasmal.web.questEditor.models.* import world.phantasmal.web.questEditor.rendering.QuestRenderContext import world.phantasmal.web.questEditor.stores.QuestEditorStore import kotlin.math.PI @@ -19,6 +19,10 @@ class StateContext( val renderContext: QuestRenderContext, val cameraInputManager: OrbitalCameraInputManager, ) { + val quest: Val = questEditorStore.currentQuest + val area: Val = questEditorStore.currentArea + val wave: Val = questEditorStore.selectedWave + fun setHighlightedEntity(entity: QuestEntityModel<*, *>?) { questEditorStore.setHighlightedEntity(entity) } @@ -27,28 +31,11 @@ class StateContext( questEditorStore.setSelectedEntity(entity) } - /** - * @param pointerPosition pointer position in normalized device space - */ - fun translateEntity( - entity: QuestEntityModel<*, *>, - dragAdjust: Vector3, - grabOffset: Vector3, - pointerPosition: Vector2, - vertically: Boolean, - ) { - if (vertically) { - translateEntityVertically(entity, dragAdjust, grabOffset, pointerPosition) - } else { - translateEntityHorizontally(entity, dragAdjust, grabOffset, pointerPosition) - } - } - /** * If the drag-adjusted pointer is over the ground, translate an entity horizontally across the * ground. Otherwise translate the entity over the horizontal plane that intersects its origin. */ - private fun translateEntityHorizontally( + fun translateEntityHorizontally( entity: QuestEntityModel<*, *>, dragAdjust: Vector3, grabOffset: Vector3, @@ -80,7 +67,7 @@ class StateContext( } } - private fun translateEntityVertically( + fun translateEntityVertically( entity: QuestEntityModel<*, *>, dragAdjust: Vector3, grabOffset: Vector3, @@ -185,6 +172,14 @@ class StateContext( )) } + fun finalizeEntityCreation(quest: QuestModel, entity: QuestEntityModel<*, *>) { + questEditorStore.pushAction(CreateEntityAction( + ::setSelectedEntity, + quest, + entity, + )) + } + /** * @param origin position in normalized device space. */ diff --git a/web/src/main/kotlin/world/phantasmal/web/questEditor/rendering/input/state/TranslationState.kt b/web/src/main/kotlin/world/phantasmal/web/questEditor/rendering/input/state/TranslationState.kt index a97fe46a..c491b83b 100644 --- a/web/src/main/kotlin/world/phantasmal/web/questEditor/rendering/input/state/TranslationState.kt +++ b/web/src/main/kotlin/world/phantasmal/web/questEditor/rendering/input/state/TranslationState.kt @@ -57,13 +57,22 @@ class TranslationState( override fun beforeRender() { if (shouldTranslate) { - ctx.translateEntity( - entity, - dragAdjust, - grabOffset, - pointerDevicePosition, - shouldTranslateVertically, - ) + if (shouldTranslateVertically) { + ctx.translateEntityVertically( + entity, + dragAdjust, + grabOffset, + pointerDevicePosition, + ) + } else { + ctx.translateEntityHorizontally( + entity, + dragAdjust, + grabOffset, + pointerDevicePosition, + ) + } + shouldTranslate = false } } diff --git a/web/src/main/kotlin/world/phantasmal/web/questEditor/stores/AreaStore.kt b/web/src/main/kotlin/world/phantasmal/web/questEditor/stores/AreaStore.kt index 8f0886aa..dc947733 100644 --- a/web/src/main/kotlin/world/phantasmal/web/questEditor/stores/AreaStore.kt +++ b/web/src/main/kotlin/world/phantasmal/web/questEditor/stores/AreaStore.kt @@ -1,6 +1,5 @@ package world.phantasmal.web.questEditor.stores -import kotlinx.coroutines.CoroutineScope import world.phantasmal.lib.fileFormats.quest.Episode import world.phantasmal.web.questEditor.loading.AreaAssetLoader import world.phantasmal.web.questEditor.models.AreaModel @@ -9,10 +8,7 @@ import world.phantasmal.web.questEditor.models.SectionModel import world.phantasmal.webui.stores.Store import world.phantasmal.lib.fileFormats.quest.getAreasForEpisode as getAreasForEpisodeLib -class AreaStore( - scope: CoroutineScope, - private val areaAssetLoader: AreaAssetLoader, -) : Store(scope) { +class AreaStore(private val areaAssetLoader: AreaAssetLoader) : Store() { private val areas: Map> init { diff --git a/web/src/main/kotlin/world/phantasmal/web/questEditor/stores/AssemblyEditorStore.kt b/web/src/main/kotlin/world/phantasmal/web/questEditor/stores/AssemblyEditorStore.kt index 4a81e05b..d8ab3626 100644 --- a/web/src/main/kotlin/world/phantasmal/web/questEditor/stores/AssemblyEditorStore.kt +++ b/web/src/main/kotlin/world/phantasmal/web/questEditor/stores/AssemblyEditorStore.kt @@ -1,30 +1,27 @@ package world.phantasmal.web.questEditor.stores -import kotlinx.coroutines.CoroutineScope import world.phantasmal.lib.assembly.disassemble import world.phantasmal.observable.value.Val +import world.phantasmal.observable.value.map import world.phantasmal.observable.value.trueVal import world.phantasmal.web.externals.monacoEditor.* import world.phantasmal.webui.obj import world.phantasmal.webui.stores.Store import kotlin.js.RegExp -class AssemblyEditorStore( - scope: CoroutineScope, - questEditorStore: QuestEditorStore, -) : Store(scope) { +class AssemblyEditorStore(questEditorStore: QuestEditorStore) : Store() { private var _textModel: ITextModel? = null val inlineStackArgs: Val = trueVal() val textModel: Val = - questEditorStore.currentQuest.map(inlineStackArgs) { quest, inlineArgs -> + map(questEditorStore.currentQuest, inlineStackArgs) { quest, inlineArgs -> _textModel?.dispose() _textModel = if (quest == null) null else { - val assembly = disassemble(quest.byteCodeIr, inlineArgs) + val assembly = disassemble(quest.bytecodeIr, inlineArgs) createModel(assembly.joinToString("\n"), ASM_LANG_ID) } diff --git a/web/src/main/kotlin/world/phantasmal/web/questEditor/stores/QuestEditorStore.kt b/web/src/main/kotlin/world/phantasmal/web/questEditor/stores/QuestEditorStore.kt index 25865b6d..be2ede4b 100644 --- a/web/src/main/kotlin/world/phantasmal/web/questEditor/stores/QuestEditorStore.kt +++ b/web/src/main/kotlin/world/phantasmal/web/questEditor/stores/QuestEditorStore.kt @@ -1,8 +1,11 @@ package world.phantasmal.web.questEditor.stores -import kotlinx.coroutines.CoroutineScope import mu.KotlinLogging -import world.phantasmal.observable.value.* +import world.phantasmal.observable.value.Val +import world.phantasmal.observable.value.and +import world.phantasmal.observable.value.list.emptyListVal +import world.phantasmal.observable.value.mutableVal +import world.phantasmal.observable.value.not import world.phantasmal.web.core.PwToolType import world.phantasmal.web.core.actions.Action import world.phantasmal.web.core.stores.UiStore @@ -15,10 +18,9 @@ import world.phantasmal.webui.stores.Store private val logger = KotlinLogging.logger {} class QuestEditorStore( - scope: CoroutineScope, private val uiStore: UiStore, private val areaStore: AreaStore, -) : Store(scope) { +) : Store() { private val _currentQuest = mutableVal(null) private val _currentArea = mutableVal(null) private val _selectedWave = mutableVal(null) @@ -52,7 +54,23 @@ class QuestEditorStore( init { observe(uiStore.currentTool) { tool -> if (tool == PwToolType.QuestEditor) { - mainUndo.makeCurrent() + makeMainUndoCurrent() + } + } + + observe(currentQuest.flatMap { it?.npcs ?: emptyListVal() }) { npcs -> + val selected = selectedEntity.value + + if (selected is QuestNpcModel && selected !in npcs) { + _selectedEntity.value = null + } + } + + observe(currentQuest.flatMap { it?.objects ?: emptyListVal() }) { objects -> + val selected = selectedEntity.value + + if (selected is QuestObjectModel && selected !in objects) { + _selectedEntity.value = null } } } @@ -143,6 +161,11 @@ class QuestEditorStore( } fun executeAction(action: Action) { + pushAction(action) + action.execute() + } + + fun pushAction(action: Action) { require(questEditingEnabled.value) { val reason = when { currentQuest.value == null -> " (no current quest)" @@ -151,6 +174,6 @@ class QuestEditorStore( } "Quest editing is disabled at the moment$reason." } - mainUndo.push(action).execute() + mainUndo.push(action) } } diff --git a/web/src/main/kotlin/world/phantasmal/web/questEditor/widgets/AssemblyEditorWidget.kt b/web/src/main/kotlin/world/phantasmal/web/questEditor/widgets/AssemblyEditorWidget.kt index 0cbbcc81..cffa7dbc 100644 --- a/web/src/main/kotlin/world/phantasmal/web/questEditor/widgets/AssemblyEditorWidget.kt +++ b/web/src/main/kotlin/world/phantasmal/web/questEditor/widgets/AssemblyEditorWidget.kt @@ -1,6 +1,5 @@ package world.phantasmal.web.questEditor.widgets -import kotlinx.coroutines.CoroutineScope import org.w3c.dom.Node import world.phantasmal.core.disposable.disposable import world.phantasmal.web.externals.monacoEditor.IStandaloneCodeEditor @@ -12,10 +11,7 @@ import world.phantasmal.webui.dom.div import world.phantasmal.webui.obj import world.phantasmal.webui.widgets.Widget -class AssemblyEditorWidget( - scope: CoroutineScope, - private val ctrl: AssemblyEditorController, -) : Widget(scope) { +class AssemblyEditorWidget(private val ctrl: AssemblyEditorController) : Widget() { private lateinit var editor: IStandaloneCodeEditor override fun Node.createElement() = diff --git a/web/src/main/kotlin/world/phantasmal/web/questEditor/widgets/EntityDnd.kt b/web/src/main/kotlin/world/phantasmal/web/questEditor/widgets/EntityDnd.kt new file mode 100644 index 00000000..00c497dc --- /dev/null +++ b/web/src/main/kotlin/world/phantasmal/web/questEditor/widgets/EntityDnd.kt @@ -0,0 +1,192 @@ +package world.phantasmal.web.questEditor.widgets + +import kotlinx.browser.document +import kotlinx.browser.window +import org.w3c.dom.DragEvent +import org.w3c.dom.HTMLElement +import org.w3c.dom.Image +import org.w3c.dom.events.Event +import world.phantasmal.core.disposable.Disposable +import world.phantasmal.core.disposable.TrackedDisposable +import world.phantasmal.lib.fileFormats.quest.EntityType +import world.phantasmal.web.externals.three.Vector2 +import world.phantasmal.webui.dom.disposableListener +import world.phantasmal.webui.dom.getRoot + +private const val DATA_TYPE_PREFIX = "phantasmal-world-id-" + +private val eventData: MutableMap = mutableMapOf() +private var nextEventId = 0 +private var dragSources = 0 + +// Store a references to dragEnd and dragOver because because KJS generates a new object every time +// you use :: at the moment. So e.g. ::dragEnd != ::dragEnd. +@Suppress("UNCHECKED_CAST") +private val dragEndReference: (Event) -> Unit = ::dragEnd as (Event) -> Unit + +@Suppress("UNCHECKED_CAST") +private val dragOverReference: (Event) -> Unit = ::dragOver as (Event) -> Unit + +class EntityDragEvent(private val data: EventData, private val event: DragEvent) { + val entityType: EntityType = data.entityType + val clientX = event.clientX + val clientY = event.clientY + val shiftKeyDown = event.shiftKey + + fun allowDrop() { + event.stopPropagation() + event.preventDefault() + event.dataTransfer?.dropEffect = "copy" + } + + fun showDragElement() { + data.dragElement.hidden = false + } + + fun hideDragElement() { + data.dragElement.hidden = true + } +} + +fun HTMLElement.entityDndSource(entityType: EntityType, imageUrl: String): Disposable = + disposableListener("dragstart", { e: DragEvent -> + dragStart(e, entityType, imageUrl) + }) + +fun HTMLElement.observeEntityDragEnter(observer: (EntityDragEvent) -> Unit): Disposable = + observeEntityEvent("dragenter", observer) + +fun HTMLElement.observeEntityDragOver(observer: (EntityDragEvent) -> Unit): Disposable = + observeEntityEvent("dragover", observer) + +fun HTMLElement.observeEntityDragLeave(observer: (EntityDragEvent) -> Unit): Disposable = + observeEntityEvent("dragleave", observer) + +fun HTMLElement.observeEntityDrop(observer: (EntityDragEvent) -> Unit): Disposable = + observeEntityEvent("drop", observer) + +/** + * Shouldn't be used outside of this file. + */ +class EventData( + val id: String, + val entityType: EntityType, + imageUrl: String, + val position: Vector2, + private val grabPoint: Vector2, +) : TrackedDisposable() { + val dragElement = Image(100, 100) + + init { + dragElement.src = imageUrl + dragElement.style.position = "fixed" + (dragElement.style.asDynamic()).pointerEvents = "none" + dragElement.style.zIndex = "500" + dragElement.style.top = "0" + dragElement.style.left = "0" + + updateTransform() + + getRoot().append(dragElement) + } + + fun setPosition(x: Int, y: Int) { + position.set(x.toDouble(), y.toDouble()) + updateTransform() + } + + private fun updateTransform() { + dragElement.style.transform = + "translate(${position.x - grabPoint.x}px, ${position.y - grabPoint.y}px)" + } + + override fun internalDispose() { + dragElement.remove() + super.internalDispose() + } +} + +private fun HTMLElement.observeEntityEvent( + type: String, + observer: (EntityDragEvent) -> Unit, +): Disposable = + disposableListener(type, { e: DragEvent -> + getEventData(e)?.let { data -> + observer(EntityDragEvent(data, e)) + } + }) + +private fun dragStart(e: DragEvent, entityType: EntityType, imageUrl: String) { + val dataTransfer = e.dataTransfer + + if (dataTransfer == null) { + e.preventDefault() + return + } + + val eventId = (nextEventId++).toString() + val position = Vector2(e.clientX.toDouble(), e.clientY.toDouble()) + val grabPoint = Vector2(e.offsetX, e.offsetY) + + eventData[eventId] = EventData(eventId, entityType, imageUrl, position, grabPoint) + + dataTransfer.effectAllowed = "copy" + dataTransfer.setDragImage(document.createElement("div"), 0, 0) + dataTransfer.setData(DATA_TYPE_PREFIX + eventId, eventId) + dataTransfer.setData("text/plain", entityType.simpleName) + + if (++dragSources == 1) { + window.addEventListener("dragover", dragOverReference) + window.addEventListener("dragend", dragEndReference) + } +} + +private fun dragOver(e: DragEvent) { + getEventData(e)?.setPosition(e.clientX, e.clientY) +} + +private fun dragEnd(e: DragEvent) { + if (--dragSources == 0) { + window.removeEventListener("dragover", dragOverReference) + window.removeEventListener("dragend", dragEndReference) + } + + getEventData(e)?.let { data -> + eventData.remove(data.id) + data.dispose() + } +} + +private fun getEventData(e: DragEvent): EventData? { + val pos = Vector2(e.clientX.toDouble(), e.clientY.toDouble()) + var data: EventData? = null + + if (e.type == "dragend") { + // In this case, e.dataTransfer.types will be empty and we can't retrieve the id anymore. + var closestDist = Double.POSITIVE_INFINITY + + for (d in eventData.values) { + val dist = d.position.distanceTo(pos) + + if (dist < closestDist) { + closestDist = dist + data = d + } + } + } else { + data = getEventId(e)?.let { eventData[it] } + } + + // Position is 0,0 in the last dragleave event before dragend. + if (e.type != "dragleave") { + data?.position?.copy(pos) + } + + return data +} + +private fun getEventId(e: DragEvent): String? = + e.dataTransfer + ?.types + ?.find { it.startsWith(DATA_TYPE_PREFIX) } + ?.drop(DATA_TYPE_PREFIX.length) diff --git a/web/src/main/kotlin/world/phantasmal/web/questEditor/widgets/EntityInfoWidget.kt b/web/src/main/kotlin/world/phantasmal/web/questEditor/widgets/EntityInfoWidget.kt index 3f2a0432..97fe722d 100644 --- a/web/src/main/kotlin/world/phantasmal/web/questEditor/widgets/EntityInfoWidget.kt +++ b/web/src/main/kotlin/world/phantasmal/web/questEditor/widgets/EntityInfoWidget.kt @@ -1,6 +1,5 @@ package world.phantasmal.web.questEditor.widgets -import kotlinx.coroutines.CoroutineScope import org.w3c.dom.Node import world.phantasmal.web.core.widgets.UnavailableWidget import world.phantasmal.web.questEditor.controllers.EntityInfoController @@ -8,10 +7,7 @@ import world.phantasmal.webui.dom.* import world.phantasmal.webui.widgets.DoubleInput import world.phantasmal.webui.widgets.Widget -class EntityInfoWidget( - scope: CoroutineScope, - private val ctrl: EntityInfoController, -) : Widget(scope, enabled = ctrl.enabled) { +class EntityInfoWidget(private val ctrl: EntityInfoController) : Widget(enabled = ctrl.enabled) { override fun Node.createElement() = div { className = "pw-quest-editor-entity-info" @@ -45,7 +41,6 @@ class EntityInfoWidget( th { className = COORD_CLASS; textContent = "X:" } td { addChild(DoubleInput( - this@EntityInfoWidget.scope, enabled = ctrl.enabled, value = ctrl.posX, onChange = ctrl::setPosX, @@ -57,7 +52,6 @@ class EntityInfoWidget( th { className = COORD_CLASS; textContent = "Y:" } td { addChild(DoubleInput( - this@EntityInfoWidget.scope, enabled = ctrl.enabled, value = ctrl.posY, onChange = ctrl::setPosY, @@ -69,7 +63,6 @@ class EntityInfoWidget( th { className = COORD_CLASS; textContent = "Z:" } td { addChild(DoubleInput( - this@EntityInfoWidget.scope, enabled = ctrl.enabled, value = ctrl.posZ, onChange = ctrl::setPosZ, @@ -84,7 +77,6 @@ class EntityInfoWidget( th { className = COORD_CLASS; textContent = "X:" } td { addChild(DoubleInput( - this@EntityInfoWidget.scope, enabled = ctrl.enabled, value = ctrl.rotX, onChange = ctrl::setRotX, @@ -96,7 +88,6 @@ class EntityInfoWidget( th { className = COORD_CLASS; textContent = "Y:" } td { addChild(DoubleInput( - this@EntityInfoWidget.scope, enabled = ctrl.enabled, value = ctrl.rotY, onChange = ctrl::setRotY, @@ -108,7 +99,6 @@ class EntityInfoWidget( th { className = COORD_CLASS; textContent = "Z:" } td { addChild(DoubleInput( - this@EntityInfoWidget.scope, enabled = ctrl.enabled, value = ctrl.rotZ, onChange = ctrl::setRotZ, @@ -118,7 +108,6 @@ class EntityInfoWidget( } } addChild(UnavailableWidget( - scope, visible = ctrl.unavailable, message = "No entity selected.", )) diff --git a/web/src/main/kotlin/world/phantasmal/web/questEditor/widgets/EntityListWidget.kt b/web/src/main/kotlin/world/phantasmal/web/questEditor/widgets/EntityListWidget.kt index 95cd9ed9..fc903303 100644 --- a/web/src/main/kotlin/world/phantasmal/web/questEditor/widgets/EntityListWidget.kt +++ b/web/src/main/kotlin/world/phantasmal/web/questEditor/widgets/EntityListWidget.kt @@ -1,17 +1,19 @@ package world.phantasmal.web.questEditor.widgets -import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch import org.w3c.dom.Node +import world.phantasmal.lib.fileFormats.quest.EntityType import world.phantasmal.web.questEditor.controllers.EntityListController +import world.phantasmal.web.questEditor.rendering.EntityImageRenderer import world.phantasmal.webui.dom.div import world.phantasmal.webui.dom.img import world.phantasmal.webui.dom.span import world.phantasmal.webui.widgets.Widget class EntityListWidget( - scope: CoroutineScope, private val ctrl: EntityListController, -) : Widget(scope, enabled = ctrl.enabled) { + private val entityImageRenderer: EntityImageRenderer, +) : Widget(enabled = ctrl.enabled) { override fun Node.createElement() = div { className = "pw-quest-editor-entity-list" @@ -20,23 +22,38 @@ class EntityListWidget( div { className = "pw-quest-editor-entity-list-inner" - bindChildrenTo(ctrl.entities) { entityType, index -> - div { - className = "pw-quest-editor-entity-list-entity" - - img { - width = 100 - height = 100 - } - - span { - textContent = entityType.simpleName - } - } + bindChildWidgetsTo(ctrl.entities) { entityType, _ -> + EntityListEntityWidget(entityType) } } } + private inner class EntityListEntityWidget(private val entityType: EntityType) : Widget() { + override fun Node.createElement() = + div { + className = "pw-quest-editor-entity-list-entity" + draggable = true + + img { + width = 100 + height = 100 + style.visibility = "hidden" + style.asDynamic().pointerEvents = "none" + + scope.launch { + src = entityImageRenderer.renderToImage(entityType) + style.visibility = "" + + addDisposable(this@div.entityDndSource(entityType, src)) + } + } + + span { + textContent = entityType.simpleName + } + } + } + companion object { init { @Suppress("CssUnusedSymbol") diff --git a/web/src/main/kotlin/world/phantasmal/web/questEditor/widgets/NpcCountsWidget.kt b/web/src/main/kotlin/world/phantasmal/web/questEditor/widgets/NpcCountsWidget.kt index 534192bd..e8a08c8a 100644 --- a/web/src/main/kotlin/world/phantasmal/web/questEditor/widgets/NpcCountsWidget.kt +++ b/web/src/main/kotlin/world/phantasmal/web/questEditor/widgets/NpcCountsWidget.kt @@ -1,6 +1,5 @@ package world.phantasmal.web.questEditor.widgets -import kotlinx.coroutines.CoroutineScope import org.w3c.dom.Node import world.phantasmal.web.core.widgets.UnavailableWidget import world.phantasmal.web.questEditor.controllers.NpcCountsController @@ -8,9 +7,8 @@ import world.phantasmal.webui.dom.* import world.phantasmal.webui.widgets.Widget class NpcCountsWidget( - scope: CoroutineScope, private val ctrl: NpcCountsController, -) : Widget(scope) { +) : Widget() { override fun Node.createElement() = div { className = "pw-quest-editor-npc-counts" @@ -26,7 +24,6 @@ class NpcCountsWidget( } } addChild(UnavailableWidget( - scope, visible = ctrl.unavailable, message = "No quest loaded." )) diff --git a/web/src/main/kotlin/world/phantasmal/web/questEditor/widgets/QuestEditorRendererWidget.kt b/web/src/main/kotlin/world/phantasmal/web/questEditor/widgets/QuestEditorRendererWidget.kt index 32f27ed6..85c4f570 100644 --- a/web/src/main/kotlin/world/phantasmal/web/questEditor/widgets/QuestEditorRendererWidget.kt +++ b/web/src/main/kotlin/world/phantasmal/web/questEditor/widgets/QuestEditorRendererWidget.kt @@ -1,9 +1,7 @@ package world.phantasmal.web.questEditor.widgets -import kotlinx.coroutines.CoroutineScope import world.phantasmal.web.questEditor.rendering.QuestRenderer class QuestEditorRendererWidget( - scope: CoroutineScope, renderer: QuestRenderer, -) : QuestRendererWidget(scope, renderer) +) : QuestRendererWidget(renderer) diff --git a/web/src/main/kotlin/world/phantasmal/web/questEditor/widgets/QuestEditorToolbarWidget.kt b/web/src/main/kotlin/world/phantasmal/web/questEditor/widgets/QuestEditorToolbarWidget.kt index 3df0916a..2fa29d18 100644 --- a/web/src/main/kotlin/world/phantasmal/web/questEditor/widgets/QuestEditorToolbarWidget.kt +++ b/web/src/main/kotlin/world/phantasmal/web/questEditor/widgets/QuestEditorToolbarWidget.kt @@ -1,6 +1,5 @@ package world.phantasmal.web.questEditor.widgets -import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch import org.w3c.dom.Node import world.phantasmal.lib.fileFormats.quest.Episode @@ -10,25 +9,19 @@ import world.phantasmal.webui.dom.Icon import world.phantasmal.webui.dom.div import world.phantasmal.webui.widgets.* -class QuestEditorToolbarWidget( - scope: CoroutineScope, - private val ctrl: QuestEditorToolbarController, -) : Widget(scope) { +class QuestEditorToolbarWidget(private val ctrl: QuestEditorToolbarController) : Widget() { override fun Node.createElement() = div { className = "pw-quest-editor-toolbar" addChild(Toolbar( - scope, children = listOf( Button( - scope, text = "New quest", iconLeft = Icon.NewFile, onClick = { scope.launch { ctrl.createNewQuest(Episode.I) } }, ), FileButton( - scope, text = "Open file...", tooltip = value("Open a quest file (Ctrl-O)"), iconLeft = Icon.File, @@ -37,7 +30,6 @@ class QuestEditorToolbarWidget( filesSelected = { files -> scope.launch { ctrl.openFiles(files) } }, ), Button( - scope, text = "Undo", iconLeft = Icon.Undo, enabled = ctrl.undoEnabled, @@ -45,7 +37,6 @@ class QuestEditorToolbarWidget( onClick = { ctrl.undo() }, ), Button( - scope, text = "Redo", iconLeft = Icon.Redo, enabled = ctrl.redoEnabled, @@ -53,7 +44,6 @@ class QuestEditorToolbarWidget( onClick = { ctrl.redo() }, ), Select( - scope, enabled = ctrl.areaSelectEnabled, itemsVal = ctrl.areas, itemToString = { it.label }, diff --git a/web/src/main/kotlin/world/phantasmal/web/questEditor/widgets/QuestEditorWidget.kt b/web/src/main/kotlin/world/phantasmal/web/questEditor/widgets/QuestEditorWidget.kt index f64f97a2..6f1a2c1d 100644 --- a/web/src/main/kotlin/world/phantasmal/web/questEditor/widgets/QuestEditorWidget.kt +++ b/web/src/main/kotlin/world/phantasmal/web/questEditor/widgets/QuestEditorWidget.kt @@ -1,6 +1,5 @@ package world.phantasmal.web.questEditor.widgets -import kotlinx.coroutines.CoroutineScope import org.w3c.dom.Node import world.phantasmal.web.core.widgets.DockWidget import world.phantasmal.web.questEditor.controllers.QuestEditorController @@ -19,34 +18,32 @@ import world.phantasmal.webui.widgets.Widget * Takes ownership of the widgets created by the given creation functions. */ class QuestEditorWidget( - scope: CoroutineScope, private val ctrl: QuestEditorController, - private val createToolbar: (CoroutineScope) -> QuestEditorToolbarWidget, - private val createQuestInfoWidget: (CoroutineScope) -> QuestInfoWidget, - private val createNpcCountsWidget: (CoroutineScope) -> NpcCountsWidget, - private val createEntityInfoWidget: (CoroutineScope) -> EntityInfoWidget, - private val createQuestRendererWidget: (CoroutineScope) -> QuestRendererWidget, - private val createAssemblyEditorWidget: (CoroutineScope) -> AssemblyEditorWidget, - private val createNpcListWidget: (CoroutineScope) -> EntityListWidget, - private val createObjectListWidget: (CoroutineScope) -> EntityListWidget, -) : Widget(scope) { + private val createToolbar: () -> QuestEditorToolbarWidget, + private val createQuestInfoWidget: () -> QuestInfoWidget, + private val createNpcCountsWidget: () -> NpcCountsWidget, + private val createEntityInfoWidget: () -> EntityInfoWidget, + private val createQuestRendererWidget: () -> QuestRendererWidget, + private val createAssemblyEditorWidget: () -> AssemblyEditorWidget, + private val createNpcListWidget: () -> EntityListWidget, + private val createObjectListWidget: () -> EntityListWidget, +) : Widget() { override fun Node.createElement() = div { className = "pw-quest-editor-quest-editor" - addChild(createToolbar(scope)) + addChild(createToolbar()) addChild(DockWidget( - scope, ctrl = ctrl, - createWidget = { scope, id -> + createWidget = { id -> when (id) { - QUEST_INFO_WIDGET_ID -> createQuestInfoWidget(scope) - NPC_COUNTS_WIDGET_ID -> createNpcCountsWidget(scope) - ENTITY_INFO_WIDGET_ID -> createEntityInfoWidget(scope) - QUEST_RENDERER_WIDGET_ID -> createQuestRendererWidget(scope) - ASSEMBLY_EDITOR_WIDGET_ID -> createAssemblyEditorWidget(scope) - NPC_LIST_WIDGET_ID -> createNpcListWidget(scope) - OBJECT_LIST_WIDGET_ID -> createObjectListWidget(scope) + QUEST_INFO_WIDGET_ID -> createQuestInfoWidget() + NPC_COUNTS_WIDGET_ID -> createNpcCountsWidget() + ENTITY_INFO_WIDGET_ID -> createEntityInfoWidget() + QUEST_RENDERER_WIDGET_ID -> createQuestRendererWidget() + ASSEMBLY_EDITOR_WIDGET_ID -> createAssemblyEditorWidget() + NPC_LIST_WIDGET_ID -> createNpcListWidget() + OBJECT_LIST_WIDGET_ID -> createObjectListWidget() EVENTS_WIDGET_ID -> null // TODO: EventsWidget. else -> null } diff --git a/web/src/main/kotlin/world/phantasmal/web/questEditor/widgets/QuestInfoWidget.kt b/web/src/main/kotlin/world/phantasmal/web/questEditor/widgets/QuestInfoWidget.kt index faf93c86..d8a99572 100644 --- a/web/src/main/kotlin/world/phantasmal/web/questEditor/widgets/QuestInfoWidget.kt +++ b/web/src/main/kotlin/world/phantasmal/web/questEditor/widgets/QuestInfoWidget.kt @@ -1,6 +1,5 @@ package world.phantasmal.web.questEditor.widgets -import kotlinx.coroutines.CoroutineScope import org.w3c.dom.Node import world.phantasmal.web.core.widgets.UnavailableWidget import world.phantasmal.web.questEditor.controllers.QuestInfoController @@ -10,10 +9,7 @@ import world.phantasmal.webui.widgets.TextArea import world.phantasmal.webui.widgets.TextInput import world.phantasmal.webui.widgets.Widget -class QuestInfoWidget( - scope: CoroutineScope, - private val ctrl: QuestInfoController, -) : Widget(scope, enabled = ctrl.enabled) { +class QuestInfoWidget(private val ctrl: QuestInfoController) : Widget(enabled = ctrl.enabled) { override fun Node.createElement() = div { className = "pw-quest-editor-quest-info" @@ -30,7 +26,6 @@ class QuestInfoWidget( th { textContent = "ID:" } td { addChild(IntInput( - this@QuestInfoWidget.scope, enabled = ctrl.enabled, value = ctrl.id, onChange = ctrl::setId, @@ -43,7 +38,6 @@ class QuestInfoWidget( th { textContent = "Name:" } td { addChild(TextInput( - this@QuestInfoWidget.scope, enabled = ctrl.enabled, value = ctrl.name, onChange = ctrl::setName, @@ -61,7 +55,6 @@ class QuestInfoWidget( td { colSpan = 2 addChild(TextArea( - this@QuestInfoWidget.scope, enabled = ctrl.enabled, value = ctrl.shortDescription, onChange = ctrl::setShortDescription, @@ -82,7 +75,6 @@ class QuestInfoWidget( td { colSpan = 2 addChild(TextArea( - this@QuestInfoWidget.scope, enabled = ctrl.enabled, value = ctrl.longDescription, onChange = ctrl::setLongDescription, @@ -95,7 +87,6 @@ class QuestInfoWidget( } } addChild(UnavailableWidget( - scope, visible = ctrl.unavailable, message = "No quest loaded." )) diff --git a/web/src/main/kotlin/world/phantasmal/web/questEditor/widgets/QuestRendererWidget.kt b/web/src/main/kotlin/world/phantasmal/web/questEditor/widgets/QuestRendererWidget.kt index 2f91d6e2..5dafe46d 100644 --- a/web/src/main/kotlin/world/phantasmal/web/questEditor/widgets/QuestRendererWidget.kt +++ b/web/src/main/kotlin/world/phantasmal/web/questEditor/widgets/QuestRendererWidget.kt @@ -1,6 +1,5 @@ package world.phantasmal.web.questEditor.widgets -import kotlinx.coroutines.CoroutineScope import org.w3c.dom.Node import world.phantasmal.web.core.widgets.RendererWidget import world.phantasmal.web.questEditor.rendering.QuestRenderer @@ -8,14 +7,13 @@ import world.phantasmal.webui.dom.div import world.phantasmal.webui.widgets.Widget abstract class QuestRendererWidget( - scope: CoroutineScope, private val renderer: QuestRenderer, -) : Widget(scope) { +) : Widget() { override fun Node.createElement() = div { className = "pw-quest-editor-quest-renderer" - addChild(RendererWidget(scope, renderer)) + addChild(RendererWidget(renderer)) } companion object { diff --git a/web/src/main/kotlin/world/phantasmal/web/viewer/Viewer.kt b/web/src/main/kotlin/world/phantasmal/web/viewer/Viewer.kt index 752a0f83..35401fff 100644 --- a/web/src/main/kotlin/world/phantasmal/web/viewer/Viewer.kt +++ b/web/src/main/kotlin/world/phantasmal/web/viewer/Viewer.kt @@ -1,6 +1,5 @@ package world.phantasmal.web.viewer -import kotlinx.coroutines.CoroutineScope import org.w3c.dom.HTMLCanvasElement import world.phantasmal.web.core.PwTool import world.phantasmal.web.core.PwToolType @@ -21,9 +20,9 @@ class Viewer( ) : DisposableContainer(), PwTool { override val toolType = PwToolType.Viewer - override fun initialize(scope: CoroutineScope): Widget { + override fun initialize(): Widget { // Stores - val viewerStore = addDisposable(ViewerStore(scope)) + val viewerStore = addDisposable(ViewerStore()) // Controllers val viewerController = addDisposable(ViewerController()) @@ -39,11 +38,10 @@ class Viewer( // Main Widget return ViewerWidget( - scope, viewerController, - { s -> ViewerToolbar(s, viewerToolbarController) }, - { s -> RendererWidget(s, meshRenderer) }, - { s -> RendererWidget(s, textureRenderer) }, + { ViewerToolbar(viewerToolbarController) }, + { RendererWidget(meshRenderer) }, + { RendererWidget(textureRenderer) }, ) } } diff --git a/web/src/main/kotlin/world/phantasmal/web/viewer/controller/ViewerToolbarController.kt b/web/src/main/kotlin/world/phantasmal/web/viewer/controller/ViewerToolbarController.kt index 8eff5c91..d1749a4f 100644 --- a/web/src/main/kotlin/world/phantasmal/web/viewer/controller/ViewerToolbarController.kt +++ b/web/src/main/kotlin/world/phantasmal/web/viewer/controller/ViewerToolbarController.kt @@ -7,10 +7,11 @@ import world.phantasmal.core.PwResult import world.phantasmal.core.Severity import world.phantasmal.core.Success import world.phantasmal.lib.Endianness +import world.phantasmal.lib.compression.prs.prsDecompress +import world.phantasmal.lib.cursor.Cursor import world.phantasmal.lib.cursor.cursor -import world.phantasmal.lib.fileFormats.ninja.parseNj -import world.phantasmal.lib.fileFormats.ninja.parseXj -import world.phantasmal.lib.fileFormats.ninja.parseXvm +import world.phantasmal.lib.fileFormats.ninja.* +import world.phantasmal.lib.fileFormats.parseAfs import world.phantasmal.observable.value.Val import world.phantasmal.observable.value.mutableVal import world.phantasmal.web.viewer.store.ViewerStore @@ -38,56 +39,71 @@ class ViewerToolbarController(private val store: ViewerStore) : Controller() { var success = false try { - var modelFound = false - var textureFound = false + val kindsFound = mutableSetOf() for (file in files) { - when (file.extension()?.toLowerCase()) { - "nj" -> { - if (modelFound) continue + val extension = file.extension()?.toLowerCase() - modelFound = true - val njResult = parseNj(readFile(file).cursor(Endianness.Little)) - result.addResult(njResult) + val kind = when (extension) { + "nj", "xj" -> FileKind.Model + "afs", "xvm" -> FileKind.Texture + else -> { + result.addProblem( + Severity.Error, + """File "${file.name}" has an unsupported file type.""", + ) + continue + } + } + + if (kind in kindsFound) continue + + val cursor = readFile(file).cursor(Endianness.Little) + var fileResult: PwResult<*>? = null + + when (extension) { + "nj" -> { + val njResult = parseNj(cursor) + fileResult = njResult if (njResult is Success) { store.setCurrentNinjaObject(njResult.value.firstOrNull()) - success = true } } "xj" -> { - if (modelFound) continue - - modelFound = true - val xjResult = parseXj(readFile(file).cursor(Endianness.Little)) - result.addResult(xjResult) + val xjResult = parseXj(cursor) + fileResult = xjResult if (xjResult is Success) { store.setCurrentNinjaObject(xjResult.value.firstOrNull()) - success = true + } + } + + "afs" -> { + val afsResult = parseAfsTextures(cursor) + fileResult = afsResult + + if (afsResult is Success) { + store.setCurrentTextures(afsResult.value) } } "xvm" -> { - if (textureFound) continue - - textureFound = true - val xvmResult = parseXvm(readFile(file).cursor(Endianness.Little)) - result.addResult(xvmResult) + val xvmResult = parseXvm(cursor) + fileResult = xvmResult if (xvmResult is Success) { store.setCurrentTextures(xvmResult.value.textures) - success = true } } + } - else -> { - result.addProblem( - Severity.Error, - """File "${file.name}" has an unsupported file type.""" - ) - } + fileResult?.let(result::addResult) + + if (fileResult is Success<*>) { + success = true + kindsFound.add(kind) } } } catch (e: Exception) { @@ -105,4 +121,47 @@ class ViewerToolbarController(private val store: ViewerStore) : Controller() { _result.value = result _resultDialogVisible.value = result != null && result.problems.isNotEmpty() } + + private fun parseAfsTextures(cursor: Cursor): PwResult> { + val result = PwResult.build>(logger) + val afsResult = parseAfs(cursor) + result.addResult(afsResult) + + if (afsResult !is Success) { + return result.failure() + } + + if (afsResult.value.isEmpty()) { + result.addProblem(Severity.Info, "AFS archive is empty.") + } + + val textures: List = afsResult.value.flatMap { file -> + val fileCursor = file.cursor() + + val decompressedCursor: Cursor = + if (isXvm(fileCursor)) { + fileCursor + } else { + val decompressionResult = prsDecompress(fileCursor) + result.addResult(decompressionResult) + + if (decompressionResult !is Success) { + return@flatMap emptyList() + } + + decompressionResult.value + } + + val xvmResult = parseXvm(decompressedCursor) + result.addResult(xvmResult) + + if (xvmResult is Success) xvmResult.value.textures else emptyList() + } + + return result.success(textures) + } + + private enum class FileKind { + Model, Texture + } } diff --git a/web/src/main/kotlin/world/phantasmal/web/viewer/rendering/TextureRenderer.kt b/web/src/main/kotlin/world/phantasmal/web/viewer/rendering/TextureRenderer.kt index ef7e8a1b..a4574c88 100644 --- a/web/src/main/kotlin/world/phantasmal/web/viewer/rendering/TextureRenderer.kt +++ b/web/src/main/kotlin/world/phantasmal/web/viewer/rendering/TextureRenderer.kt @@ -1,5 +1,6 @@ package world.phantasmal.web.viewer.rendering +import mu.KotlinLogging import org.w3c.dom.HTMLCanvasElement import world.phantasmal.lib.fileFormats.ninja.XvrTexture import world.phantasmal.web.core.rendering.* @@ -12,6 +13,8 @@ import kotlin.math.ceil import kotlin.math.max import kotlin.math.sqrt +private val logger = KotlinLogging.logger {} + class TextureRenderer( store: ViewerStore, createThreeRenderer: (HTMLCanvasElement) -> DisposableThreeRenderer, @@ -36,7 +39,8 @@ class TextureRenderer( context.canvas, context.camera, Vector3(0.0, 0.0, 5.0), - screenSpacePanning = true + screenSpacePanning = true, + enableRotate = false, )) init { @@ -71,11 +75,23 @@ class TextureRenderer( var cell = 0 meshes = textures.map { xvr -> + val texture = + try { + xvrTextureToThree(xvr, filter = NearestFilter) + } catch (e: Exception) { + logger.error(e) { "Couldn't convert XVR texture." } + null + } + val quad = Mesh( createQuad(x, y, xvr.width, xvr.height), MeshBasicMaterial(obj { - map = xvrTextureToThree(xvr, filter = NearestFilter) - transparent = true + if (texture == null) { + color = Color(0xFF00FF) + } else { + map = texture + transparent = true + } }) ) context.scene.add(quad) @@ -96,7 +112,7 @@ class TextureRenderer( width.toDouble(), height.toDouble(), widthSegments = 1.0, - heightSegments = 1.0 + heightSegments = 1.0, ) quad.faceVertexUvs = arrayOf( arrayOf( diff --git a/web/src/main/kotlin/world/phantasmal/web/viewer/store/ViewerStore.kt b/web/src/main/kotlin/world/phantasmal/web/viewer/store/ViewerStore.kt index c7550d6a..1fdcef41 100644 --- a/web/src/main/kotlin/world/phantasmal/web/viewer/store/ViewerStore.kt +++ b/web/src/main/kotlin/world/phantasmal/web/viewer/store/ViewerStore.kt @@ -1,6 +1,5 @@ package world.phantasmal.web.viewer.store -import kotlinx.coroutines.CoroutineScope import world.phantasmal.lib.fileFormats.ninja.NinjaObject import world.phantasmal.lib.fileFormats.ninja.XvrTexture import world.phantasmal.observable.value.Val @@ -9,7 +8,7 @@ import world.phantasmal.observable.value.list.mutableListVal import world.phantasmal.observable.value.mutableVal import world.phantasmal.webui.stores.Store -class ViewerStore(scope: CoroutineScope) : Store(scope) { +class ViewerStore() : Store() { private val _currentNinjaObject = mutableVal?>(null) private val _currentTextures = mutableListVal(mutableListOf()) diff --git a/web/src/main/kotlin/world/phantasmal/web/viewer/widgets/ViewerToolbar.kt b/web/src/main/kotlin/world/phantasmal/web/viewer/widgets/ViewerToolbar.kt index 56dc7978..a95aef4b 100644 --- a/web/src/main/kotlin/world/phantasmal/web/viewer/widgets/ViewerToolbar.kt +++ b/web/src/main/kotlin/world/phantasmal/web/viewer/widgets/ViewerToolbar.kt @@ -1,6 +1,5 @@ package world.phantasmal.web.viewer.widgets -import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch import org.w3c.dom.Node import world.phantasmal.web.viewer.controller.ViewerToolbarController @@ -11,19 +10,14 @@ import world.phantasmal.webui.widgets.ResultDialog import world.phantasmal.webui.widgets.Toolbar import world.phantasmal.webui.widgets.Widget -class ViewerToolbar( - scope: CoroutineScope, - private val ctrl: ViewerToolbarController, -) : Widget(scope) { +class ViewerToolbar(private val ctrl: ViewerToolbarController) : Widget() { override fun Node.createElement() = div { className = "pw-viewer-toolbar" addChild(Toolbar( - scope, children = listOf( FileButton( - scope, text = "Open file...", iconLeft = Icon.File, accept = ".afs, .nj, .njm, .xj, .xvm", @@ -33,7 +27,6 @@ class ViewerToolbar( ) )) addDisposable(ResultDialog( - scope, visible = ctrl.resultDialogVisible, result = ctrl.result, message = ctrl.resultMessage, diff --git a/web/src/main/kotlin/world/phantasmal/web/viewer/widgets/ViewerWidget.kt b/web/src/main/kotlin/world/phantasmal/web/viewer/widgets/ViewerWidget.kt index fc3ce7e7..08b8ae76 100644 --- a/web/src/main/kotlin/world/phantasmal/web/viewer/widgets/ViewerWidget.kt +++ b/web/src/main/kotlin/world/phantasmal/web/viewer/widgets/ViewerWidget.kt @@ -1,6 +1,5 @@ package world.phantasmal.web.viewer.widgets -import kotlinx.coroutines.CoroutineScope import org.w3c.dom.Node import world.phantasmal.web.viewer.controller.ViewerController import world.phantasmal.web.viewer.controller.ViewerTab @@ -12,21 +11,20 @@ import world.phantasmal.webui.widgets.Widget * Takes ownership of the widget returned by [createToolbar]. */ class ViewerWidget( - scope: CoroutineScope, private val ctrl: ViewerController, - private val createToolbar: (CoroutineScope) -> Widget, - private val createMeshWidget: (CoroutineScope) -> Widget, - private val createTextureWidget: (CoroutineScope) -> Widget, -) : Widget(scope) { + private val createToolbar: () -> Widget, + private val createMeshWidget: () -> Widget, + private val createTextureWidget: () -> Widget, +) : Widget() { override fun Node.createElement() = div { className = "pw-viewer-viewer" - addChild(createToolbar(scope)) - addChild(TabContainer(scope, ctrl = ctrl, createWidget = { scope, tab -> + addChild(createToolbar()) + addChild(TabContainer(ctrl = ctrl, createWidget = { tab -> when (tab) { - ViewerTab.Mesh -> createMeshWidget(scope) - ViewerTab.Texture -> createTextureWidget(scope) + ViewerTab.Mesh -> createMeshWidget() + ViewerTab.Texture -> createTextureWidget() } })) } diff --git a/web/src/test/kotlin/world/phantasmal/web/application/ApplicationTests.kt b/web/src/test/kotlin/world/phantasmal/web/application/ApplicationTests.kt index ff29a18d..b00e31fe 100644 --- a/web/src/test/kotlin/world/phantasmal/web/application/ApplicationTests.kt +++ b/web/src/test/kotlin/world/phantasmal/web/application/ApplicationTests.kt @@ -17,7 +17,6 @@ class ApplicationTests : WebTestSuite() { disposer.add( Application( - scope, rootElement = document.body!!, assetLoader = components.assetLoader, applicationUrl = appUrl, diff --git a/web/src/test/kotlin/world/phantasmal/web/core/controllers/PathAwareTabControllerTests.kt b/web/src/test/kotlin/world/phantasmal/web/core/controllers/PathAwareTabControllerTests.kt index 4ad744a8..baaed3ba 100644 --- a/web/src/test/kotlin/world/phantasmal/web/core/controllers/PathAwareTabControllerTests.kt +++ b/web/src/test/kotlin/world/phantasmal/web/core/controllers/PathAwareTabControllerTests.kt @@ -42,7 +42,7 @@ class PathAwareTabControllerTests : WebTestSuite() { @Test fun applicationUrl_changes_when_switch_to_tool_with_tabs() = test { val appUrl = TestApplicationUrl("/") - val uiStore = disposer.add(UiStore(scope, appUrl)) + val uiStore = disposer.add(UiStore(appUrl)) disposer.add( PathAwareTabController(uiStore, PwToolType.HuntOptimizer, listOf( @@ -71,7 +71,7 @@ class PathAwareTabControllerTests : WebTestSuite() { block: (PathAwareTabController, applicationUrl: TestApplicationUrl) -> Unit, ) { val applicationUrl = TestApplicationUrl("/${PwToolType.HuntOptimizer.slug}/b") - val uiStore = disposer.add(UiStore(scope, applicationUrl)) + val uiStore = disposer.add(UiStore(applicationUrl)) uiStore.setCurrentTool(PwToolType.HuntOptimizer) val ctrl = disposer.add( diff --git a/web/src/test/kotlin/world/phantasmal/web/core/store/UiStoreTests.kt b/web/src/test/kotlin/world/phantasmal/web/core/store/UiStoreTests.kt index df05018f..a5e59209 100644 --- a/web/src/test/kotlin/world/phantasmal/web/core/store/UiStoreTests.kt +++ b/web/src/test/kotlin/world/phantasmal/web/core/store/UiStoreTests.kt @@ -11,7 +11,7 @@ class UiStoreTests : WebTestSuite() { @Test fun applicationUrl_is_initialized_correctly() = test { val applicationUrl = TestApplicationUrl("/") - val uiStore = disposer.add(UiStore(scope, applicationUrl)) + val uiStore = disposer.add(UiStore(applicationUrl)) assertEquals(PwToolType.Viewer, uiStore.currentTool.value) assertEquals("/${PwToolType.Viewer.slug}", applicationUrl.url.value) @@ -20,7 +20,7 @@ class UiStoreTests : WebTestSuite() { @Test fun applicationUrl_changes_when_tool_changes() = test { val applicationUrl = TestApplicationUrl("/") - val uiStore = disposer.add(UiStore(scope, applicationUrl)) + val uiStore = disposer.add(UiStore(applicationUrl)) PwToolType.values().forEach { tool -> uiStore.setCurrentTool(tool) @@ -33,7 +33,7 @@ class UiStoreTests : WebTestSuite() { @Test fun applicationUrl_changes_when_path_changes() = test { val applicationUrl = TestApplicationUrl("/") - val uiStore = disposer.add(UiStore(scope, applicationUrl)) + val uiStore = disposer.add(UiStore(applicationUrl)) assertEquals(PwToolType.Viewer, uiStore.currentTool.value) assertEquals("/${PwToolType.Viewer.slug}", applicationUrl.url.value) @@ -48,7 +48,7 @@ class UiStoreTests : WebTestSuite() { @Test fun currentTool_and_path_change_when_applicationUrl_changes() = test { val applicationUrl = TestApplicationUrl("/") - val uiStore = disposer.add(UiStore(scope, applicationUrl)) + val uiStore = disposer.add(UiStore(applicationUrl)) PwToolType.values().forEach { tool -> listOf("/a", "/b", "/c").forEach { path -> @@ -63,7 +63,7 @@ class UiStoreTests : WebTestSuite() { @Test fun browser_navigation_stack_is_manipulated_correctly() = test { val appUrl = TestApplicationUrl("/") - val uiStore = disposer.add(UiStore(scope, appUrl)) + val uiStore = disposer.add(UiStore(appUrl)) assertEquals("/${uiStore.defaultTool.slug}", appUrl.url.value) diff --git a/web/src/test/kotlin/world/phantasmal/web/huntOptimizer/HuntOptimizerTests.kt b/web/src/test/kotlin/world/phantasmal/web/huntOptimizer/HuntOptimizerTests.kt index f4fbcab7..3c37467c 100644 --- a/web/src/test/kotlin/world/phantasmal/web/huntOptimizer/HuntOptimizerTests.kt +++ b/web/src/test/kotlin/world/phantasmal/web/huntOptimizer/HuntOptimizerTests.kt @@ -10,9 +10,9 @@ class HuntOptimizerTests : WebTestSuite() { @Test fun initialization_and_shutdown_should_succeed_without_throwing() = test { val uiStore = - disposer.add(UiStore(scope, TestApplicationUrl("/${PwToolType.HuntOptimizer}"))) + disposer.add(UiStore(TestApplicationUrl("/${PwToolType.HuntOptimizer}"))) val huntOptimizer = disposer.add(HuntOptimizer(components.assetLoader, uiStore)) - disposer.add(huntOptimizer.initialize(scope)) + disposer.add(huntOptimizer.initialize()) } } diff --git a/web/src/test/kotlin/world/phantasmal/web/questEditor/QuestEditorTests.kt b/web/src/test/kotlin/world/phantasmal/web/questEditor/QuestEditorTests.kt index 27171936..005228a3 100644 --- a/web/src/test/kotlin/world/phantasmal/web/questEditor/QuestEditorTests.kt +++ b/web/src/test/kotlin/world/phantasmal/web/questEditor/QuestEditorTests.kt @@ -9,6 +9,6 @@ class QuestEditorTests : WebTestSuite() { val questEditor = disposer.add( QuestEditor(components.assetLoader, components.uiStore, components.createThreeRenderer) ) - disposer.add(questEditor.initialize(scope)) + disposer.add(questEditor.initialize()) } } diff --git a/web/src/test/kotlin/world/phantasmal/web/test/NoopRenderer.kt b/web/src/test/kotlin/world/phantasmal/web/test/NoopRenderer.kt deleted file mode 100644 index b0e6ed4a..00000000 --- a/web/src/test/kotlin/world/phantasmal/web/test/NoopRenderer.kt +++ /dev/null @@ -1,12 +0,0 @@ -package world.phantasmal.web.test - -import org.w3c.dom.HTMLCanvasElement -import world.phantasmal.web.externals.three.Camera -import world.phantasmal.web.externals.three.Object3D -import world.phantasmal.web.externals.three.Renderer - -class NoopRenderer(override val domElement: HTMLCanvasElement) : Renderer { - override fun render(scene: Object3D, camera: Camera) {} - - override fun setSize(width: Double, height: Double) {} -} diff --git a/web/src/test/kotlin/world/phantasmal/web/test/NopRenderer.kt b/web/src/test/kotlin/world/phantasmal/web/test/NopRenderer.kt new file mode 100644 index 00000000..7acf8b53 --- /dev/null +++ b/web/src/test/kotlin/world/phantasmal/web/test/NopRenderer.kt @@ -0,0 +1,33 @@ +package world.phantasmal.web.test + +import org.w3c.dom.HTMLCanvasElement +import world.phantasmal.web.externals.three.Camera +import world.phantasmal.web.externals.three.Color +import world.phantasmal.web.externals.three.Object3D + +// WebGLRenderer implementation. +class NopRenderer(val domElement: HTMLCanvasElement) { + @JsName("render") + fun render(scene: Object3D, camera: Camera) { + } + + @JsName("setSize") + fun setSize(width: Double, height: Double) { + } + + @JsName("setPixelRatio") + fun setPixelRatio(value: Double) { + } + + @JsName("setClearColor") + fun setClearColor(color: Color) { + } + + @JsName("clearColor") + fun clearColor() { + } + + @JsName("dispose") + fun dispose() { + } +} diff --git a/web/src/test/kotlin/world/phantasmal/web/test/TestComponents.kt b/web/src/test/kotlin/world/phantasmal/web/test/TestComponents.kt index 8070255d..bbd6f064 100644 --- a/web/src/test/kotlin/world/phantasmal/web/test/TestComponents.kt +++ b/web/src/test/kotlin/world/phantasmal/web/test/TestComponents.kt @@ -13,6 +13,7 @@ import world.phantasmal.web.core.loading.AssetLoader import world.phantasmal.web.core.rendering.DisposableThreeRenderer import world.phantasmal.web.core.stores.ApplicationUrl import world.phantasmal.web.core.stores.UiStore +import world.phantasmal.web.externals.three.WebGLRenderer import world.phantasmal.web.questEditor.loading.AreaAssetLoader import world.phantasmal.web.questEditor.loading.QuestLoader import world.phantasmal.web.questEditor.stores.AreaStore @@ -45,26 +46,26 @@ class TestComponents(private val ctx: TestContext) { var assetLoader: AssetLoader by default { AssetLoader(httpClient, basePath = "/assets") } var areaAssetLoader: AreaAssetLoader by default { - AreaAssetLoader(ctx.scope, assetLoader) + AreaAssetLoader(assetLoader) } - var questLoader: QuestLoader by default { QuestLoader(ctx.scope, assetLoader) } + var questLoader: QuestLoader by default { QuestLoader(assetLoader) } // Stores - var uiStore: UiStore by default { UiStore(ctx.scope, applicationUrl) } + var uiStore: UiStore by default { UiStore(applicationUrl) } - var areaStore: AreaStore by default { AreaStore(ctx.scope, areaAssetLoader) } + var areaStore: AreaStore by default { AreaStore(areaAssetLoader) } var questEditorStore: QuestEditorStore by default { - QuestEditorStore(ctx.scope, uiStore, areaStore) + QuestEditorStore(uiStore, areaStore) } // Rendering var createThreeRenderer: (HTMLCanvasElement) -> DisposableThreeRenderer by default { { canvas -> object : DisposableThreeRenderer { - override val renderer = NoopRenderer(canvas) + override val renderer = NopRenderer(canvas).unsafeCast() override fun dispose() {} } } diff --git a/web/src/test/kotlin/world/phantasmal/web/viewer/ViewerTests.kt b/web/src/test/kotlin/world/phantasmal/web/viewer/ViewerTests.kt index 838a5907..c7c2c357 100644 --- a/web/src/test/kotlin/world/phantasmal/web/viewer/ViewerTests.kt +++ b/web/src/test/kotlin/world/phantasmal/web/viewer/ViewerTests.kt @@ -9,6 +9,6 @@ class ViewerTests : WebTestSuite() { val viewer = disposer.add( Viewer(components.createThreeRenderer) ) - disposer.add(viewer.initialize(scope)) + disposer.add(viewer.initialize()) } } diff --git a/webui/src/main/kotlin/world/phantasmal/webui/dom/Dom.kt b/webui/src/main/kotlin/world/phantasmal/webui/dom/Dom.kt index 6a5e5d42..8c8fe212 100644 --- a/webui/src/main/kotlin/world/phantasmal/webui/dom/Dom.kt +++ b/webui/src/main/kotlin/world/phantasmal/webui/dom/Dom.kt @@ -10,17 +10,16 @@ import org.w3c.dom.pointerevents.PointerEvent import world.phantasmal.core.disposable.Disposable import world.phantasmal.core.disposable.disposable -fun disposableListener( - target: EventTarget, +fun EventTarget.disposableListener( type: String, listener: (E) -> Unit, options: AddEventListenerOptions? = null, ): Disposable { @Suppress("UNCHECKED_CAST") - target.addEventListener(type, listener as (Event) -> Unit, options) + addEventListener(type, listener as (Event) -> Unit, options) return disposable { - target.removeEventListener(type, listener) + removeEventListener(type, listener) } } @@ -34,13 +33,13 @@ fun Element.disposablePointerDrag( var windowMoveListener: Disposable? = null var windowUpListener: Disposable? = null - val downListener = disposableListener(this, "pointerdown", { downEvent -> + val downListener = disposableListener("pointerdown", { downEvent -> if (onPointerDown(downEvent)) { prevPointerX = downEvent.clientX prevPointerY = downEvent.clientY windowMoveListener = - disposableListener(window, "pointermove", { moveEvent -> + window.disposableListener("pointermove", { moveEvent -> val movedX = moveEvent.clientX - prevPointerX val movedY = moveEvent.clientY - prevPointerY prevPointerX = moveEvent.clientX @@ -53,7 +52,7 @@ fun Element.disposablePointerDrag( }) windowUpListener = - disposableListener(window, "pointerup", { upEvent -> + window.disposableListener("pointerup", { upEvent -> onPointerUp(upEvent) windowMoveListener?.dispose() windowUpListener?.dispose() diff --git a/webui/src/main/kotlin/world/phantasmal/webui/stores/Store.kt b/webui/src/main/kotlin/world/phantasmal/webui/stores/Store.kt index 59f1c089..9bf9e7c0 100644 --- a/webui/src/main/kotlin/world/phantasmal/webui/stores/Store.kt +++ b/webui/src/main/kotlin/world/phantasmal/webui/stores/Store.kt @@ -1,8 +1,15 @@ package world.phantasmal.webui.stores import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.cancel import world.phantasmal.webui.DisposableContainer -abstract class Store(protected val scope: CoroutineScope) : - DisposableContainer(), - CoroutineScope by scope +abstract class Store : DisposableContainer() { + protected val scope: CoroutineScope = CoroutineScope(Dispatchers.Default) + + override fun internalDispose() { + scope.cancel("Store disposed.") + super.internalDispose() + } +} diff --git a/webui/src/main/kotlin/world/phantasmal/webui/widgets/Button.kt b/webui/src/main/kotlin/world/phantasmal/webui/widgets/Button.kt index 64b8ea46..5e78d836 100644 --- a/webui/src/main/kotlin/world/phantasmal/webui/widgets/Button.kt +++ b/webui/src/main/kotlin/world/phantasmal/webui/widgets/Button.kt @@ -1,6 +1,5 @@ package world.phantasmal.webui.widgets -import kotlinx.coroutines.CoroutineScope import org.w3c.dom.Node import org.w3c.dom.events.KeyboardEvent import org.w3c.dom.events.MouseEvent @@ -13,7 +12,6 @@ import world.phantasmal.webui.dom.icon import world.phantasmal.webui.dom.span open class Button( - scope: CoroutineScope, visible: Val = trueVal(), enabled: Val = trueVal(), tooltip: Val = nullVal(), @@ -27,7 +25,7 @@ open class Button( private val onKeyDown: ((KeyboardEvent) -> Unit)? = null, private val onKeyUp: ((KeyboardEvent) -> Unit)? = null, private val onKeyPress: ((KeyboardEvent) -> Unit)? = null, -) : Control(scope, visible, enabled, tooltip) { +) : Control(visible, enabled, tooltip) { override fun Node.createElement() = button { className = "pw-button" diff --git a/webui/src/main/kotlin/world/phantasmal/webui/widgets/Control.kt b/webui/src/main/kotlin/world/phantasmal/webui/widgets/Control.kt index cd700535..21bf2fbf 100644 --- a/webui/src/main/kotlin/world/phantasmal/webui/widgets/Control.kt +++ b/webui/src/main/kotlin/world/phantasmal/webui/widgets/Control.kt @@ -1,6 +1,5 @@ package world.phantasmal.webui.widgets -import kotlinx.coroutines.CoroutineScope import world.phantasmal.observable.value.Val import world.phantasmal.observable.value.nullVal import world.phantasmal.observable.value.trueVal @@ -10,8 +9,7 @@ import world.phantasmal.observable.value.trueVal * etc. Controls are typically leaf nodes and thus typically don't have children. */ abstract class Control( - scope: CoroutineScope, visible: Val = trueVal(), enabled: Val = trueVal(), tooltip: Val = nullVal(), -) : Widget(scope, visible, enabled, tooltip) +) : Widget(visible, enabled, tooltip) diff --git a/webui/src/main/kotlin/world/phantasmal/webui/widgets/Dialog.kt b/webui/src/main/kotlin/world/phantasmal/webui/widgets/Dialog.kt index 27715a6f..0a398431 100644 --- a/webui/src/main/kotlin/world/phantasmal/webui/widgets/Dialog.kt +++ b/webui/src/main/kotlin/world/phantasmal/webui/widgets/Dialog.kt @@ -1,7 +1,6 @@ package world.phantasmal.webui.widgets import kotlinx.browser.window -import kotlinx.coroutines.CoroutineScope import org.w3c.dom.HTMLElement import org.w3c.dom.Node import org.w3c.dom.events.Event @@ -17,14 +16,13 @@ import world.phantasmal.webui.dom.h1 import world.phantasmal.webui.dom.section open class Dialog( - scope: CoroutineScope, visible: Val = trueVal(), enabled: Val = trueVal(), private val title: Val, private val description: Val, private val content: Val, protected val onDismiss: () -> Unit = {}, -) : Widget(scope, visible, enabled) { +) : Widget(visible, enabled) { private var x = 0 private var y = 0 diff --git a/webui/src/main/kotlin/world/phantasmal/webui/widgets/DoubleInput.kt b/webui/src/main/kotlin/world/phantasmal/webui/widgets/DoubleInput.kt index e8f9034d..04e3b920 100644 --- a/webui/src/main/kotlin/world/phantasmal/webui/widgets/DoubleInput.kt +++ b/webui/src/main/kotlin/world/phantasmal/webui/widgets/DoubleInput.kt @@ -1,6 +1,5 @@ package world.phantasmal.webui.widgets -import kotlinx.coroutines.CoroutineScope import org.w3c.dom.HTMLInputElement import world.phantasmal.observable.value.Val import world.phantasmal.observable.value.nullVal @@ -11,7 +10,6 @@ import kotlin.math.pow import kotlin.math.round class DoubleInput( - scope: CoroutineScope, visible: Val = trueVal(), enabled: Val = trueVal(), tooltip: Val = nullVal(), @@ -22,7 +20,6 @@ class DoubleInput( onChange: (Double) -> Unit = {}, roundTo: Int = 2, ) : NumberInput( - scope, visible, enabled, tooltip, diff --git a/webui/src/main/kotlin/world/phantasmal/webui/widgets/FileButton.kt b/webui/src/main/kotlin/world/phantasmal/webui/widgets/FileButton.kt index ee9afaad..725f2905 100644 --- a/webui/src/main/kotlin/world/phantasmal/webui/widgets/FileButton.kt +++ b/webui/src/main/kotlin/world/phantasmal/webui/widgets/FileButton.kt @@ -1,17 +1,14 @@ package world.phantasmal.webui.widgets -import kotlinx.coroutines.CoroutineScope import org.w3c.dom.HTMLElement import org.w3c.files.File import world.phantasmal.observable.value.Val -import world.phantasmal.observable.value.falseVal import world.phantasmal.observable.value.nullVal import world.phantasmal.observable.value.trueVal import world.phantasmal.webui.dom.Icon import world.phantasmal.webui.openFiles class FileButton( - scope: CoroutineScope, visible: Val = trueVal(), enabled: Val = trueVal(), tooltip: Val = nullVal(), @@ -22,7 +19,7 @@ class FileButton( private val accept: String = "", private val multiple: Boolean = false, private val filesSelected: ((List) -> Unit)? = null, -) : Button(scope, visible, enabled, tooltip, text, textVal, iconLeft, iconRight) { +) : Button(visible, enabled, tooltip, text, textVal, iconLeft, iconRight) { override fun interceptElement(element: HTMLElement) { element.classList.add("pw-file-button") diff --git a/webui/src/main/kotlin/world/phantasmal/webui/widgets/Input.kt b/webui/src/main/kotlin/world/phantasmal/webui/widgets/Input.kt index abd3850d..713458b9 100644 --- a/webui/src/main/kotlin/world/phantasmal/webui/widgets/Input.kt +++ b/webui/src/main/kotlin/world/phantasmal/webui/widgets/Input.kt @@ -1,6 +1,5 @@ package world.phantasmal.webui.widgets -import kotlinx.coroutines.CoroutineScope import org.w3c.dom.HTMLInputElement import org.w3c.dom.Node import world.phantasmal.observable.value.Val @@ -8,7 +7,6 @@ import world.phantasmal.webui.dom.input import world.phantasmal.webui.dom.span abstract class Input( - scope: CoroutineScope, visible: Val, enabled: Val, tooltip: Val, @@ -25,7 +23,6 @@ abstract class Input( private val max: Int?, private val step: Int?, ) : LabelledControl( - scope, visible, enabled, tooltip, @@ -58,9 +55,12 @@ abstract class Input( } this@Input.maxLength?.let { maxLength = it } - this@Input.min?.let { min = it.toString() } - this@Input.max?.let { max = it.toString() } - this@Input.step?.let { step = it.toString() } + + if (inputType == "number") { + this@Input.min?.let { min = it.toString() } + this@Input.max?.let { max = it.toString() } + step = this@Input.step?.toString() ?: "any" + } } } diff --git a/webui/src/main/kotlin/world/phantasmal/webui/widgets/IntInput.kt b/webui/src/main/kotlin/world/phantasmal/webui/widgets/IntInput.kt index 89188e5e..9d6f8fb0 100644 --- a/webui/src/main/kotlin/world/phantasmal/webui/widgets/IntInput.kt +++ b/webui/src/main/kotlin/world/phantasmal/webui/widgets/IntInput.kt @@ -1,6 +1,5 @@ package world.phantasmal.webui.widgets -import kotlinx.coroutines.CoroutineScope import org.w3c.dom.HTMLInputElement import world.phantasmal.observable.value.Val import world.phantasmal.observable.value.nullVal @@ -8,7 +7,6 @@ import world.phantasmal.observable.value.trueVal import world.phantasmal.observable.value.value class IntInput( - scope: CoroutineScope, visible: Val = trueVal(), enabled: Val = trueVal(), tooltip: Val = nullVal(), @@ -21,7 +19,6 @@ class IntInput( max: Int? = null, step: Int? = null, ) : NumberInput( - scope, visible, enabled, tooltip, diff --git a/webui/src/main/kotlin/world/phantasmal/webui/widgets/Label.kt b/webui/src/main/kotlin/world/phantasmal/webui/widgets/Label.kt index 5fd33f8b..a8ed2f68 100644 --- a/webui/src/main/kotlin/world/phantasmal/webui/widgets/Label.kt +++ b/webui/src/main/kotlin/world/phantasmal/webui/widgets/Label.kt @@ -1,19 +1,17 @@ package world.phantasmal.webui.widgets -import kotlinx.coroutines.CoroutineScope import org.w3c.dom.Node import world.phantasmal.observable.value.Val import world.phantasmal.observable.value.trueVal import world.phantasmal.webui.dom.label class Label( - scope: CoroutineScope, visible: Val = trueVal(), enabled: Val = trueVal(), private val text: String? = null, private val textVal: Val? = null, private val htmlFor: String? = null, -) : Widget(scope, visible, enabled) { +) : Widget(visible, enabled) { override fun Node.createElement() = label { className = "pw-label" diff --git a/webui/src/main/kotlin/world/phantasmal/webui/widgets/LabelledControl.kt b/webui/src/main/kotlin/world/phantasmal/webui/widgets/LabelledControl.kt index d02d7722..2232a2a2 100644 --- a/webui/src/main/kotlin/world/phantasmal/webui/widgets/LabelledControl.kt +++ b/webui/src/main/kotlin/world/phantasmal/webui/widgets/LabelledControl.kt @@ -1,6 +1,5 @@ package world.phantasmal.webui.widgets -import kotlinx.coroutines.CoroutineScope import world.phantasmal.observable.value.Val enum class LabelPosition { @@ -9,14 +8,13 @@ enum class LabelPosition { } abstract class LabelledControl( - scope: CoroutineScope, visible: Val, enabled: Val, tooltip: Val, label: String?, labelVal: Val?, val preferredLabelPosition: LabelPosition, -) : Control(scope, visible, enabled, tooltip) { +) : Control(visible, enabled, tooltip) { val label: Label? by lazy { if (label == null && labelVal == null) { null @@ -28,7 +26,7 @@ abstract class LabelledControl( element.id = id } - Label(scope, visible, enabled, label, labelVal, htmlFor = id) + Label( visible, enabled, label, labelVal, htmlFor = id) } } diff --git a/webui/src/main/kotlin/world/phantasmal/webui/widgets/LazyLoader.kt b/webui/src/main/kotlin/world/phantasmal/webui/widgets/LazyLoader.kt index 32a17f96..8dcc33e0 100644 --- a/webui/src/main/kotlin/world/phantasmal/webui/widgets/LazyLoader.kt +++ b/webui/src/main/kotlin/world/phantasmal/webui/widgets/LazyLoader.kt @@ -1,17 +1,15 @@ package world.phantasmal.webui.widgets -import kotlinx.coroutines.CoroutineScope import org.w3c.dom.Node import world.phantasmal.observable.value.Val import world.phantasmal.observable.value.trueVal import world.phantasmal.webui.dom.div class LazyLoader( - scope: CoroutineScope, visible: Val = trueVal(), enabled: Val = trueVal(), - private val createWidget: (CoroutineScope) -> Widget, -) : Widget(scope, visible, enabled) { + private val createWidget: () -> Widget, +) : Widget(visible, enabled) { private var initialized = false override fun Node.createElement() = @@ -21,7 +19,7 @@ class LazyLoader( observe(this@LazyLoader.visible) { v -> if (v && !initialized) { initialized = true - addChild(createWidget(scope)) + addChild(createWidget()) } } } diff --git a/webui/src/main/kotlin/world/phantasmal/webui/widgets/Menu.kt b/webui/src/main/kotlin/world/phantasmal/webui/widgets/Menu.kt index 4d20b968..d5e8e09f 100644 --- a/webui/src/main/kotlin/world/phantasmal/webui/widgets/Menu.kt +++ b/webui/src/main/kotlin/world/phantasmal/webui/widgets/Menu.kt @@ -1,7 +1,6 @@ package world.phantasmal.webui.widgets import kotlinx.browser.document -import kotlinx.coroutines.CoroutineScope import org.w3c.dom.* import org.w3c.dom.events.Event import org.w3c.dom.events.KeyboardEvent @@ -16,7 +15,6 @@ import world.phantasmal.webui.dom.div import world.phantasmal.webui.obj class Menu( - scope: CoroutineScope, visible: Val = trueVal(), enabled: Val = trueVal(), tooltip: Val = nullVal(), @@ -26,7 +24,6 @@ class Menu( private val onSelect: (T) -> Unit = {}, private val onCancel: () -> Unit = {}, ) : Widget( - scope, visible, enabled, tooltip, @@ -61,7 +58,7 @@ class Menu( observe(this@Menu.visible) { if (it) { onDocumentMouseDownListener = - disposableListener(document, "mousedown", ::onDocumentMouseDown) + document.disposableListener("mousedown", ::onDocumentMouseDown) } else { onDocumentMouseDownListener?.dispose() onDocumentMouseDownListener = null @@ -77,7 +74,7 @@ class Menu( } } - disposableListener(document, "keydown", ::onDocumentKeyDown) + document.disposableListener("keydown", ::onDocumentKeyDown) } override fun internalDispose() { diff --git a/webui/src/main/kotlin/world/phantasmal/webui/widgets/NumberInput.kt b/webui/src/main/kotlin/world/phantasmal/webui/widgets/NumberInput.kt index 46c306d0..03ec812a 100644 --- a/webui/src/main/kotlin/world/phantasmal/webui/widgets/NumberInput.kt +++ b/webui/src/main/kotlin/world/phantasmal/webui/widgets/NumberInput.kt @@ -1,10 +1,8 @@ package world.phantasmal.webui.widgets -import kotlinx.coroutines.CoroutineScope import world.phantasmal.observable.value.Val abstract class NumberInput( - scope: CoroutineScope, visible: Val, enabled: Val, tooltip: Val, @@ -17,7 +15,6 @@ abstract class NumberInput( max: Int?, step: Int?, ) : Input( - scope, visible, enabled, tooltip, diff --git a/webui/src/main/kotlin/world/phantasmal/webui/widgets/ResultDialog.kt b/webui/src/main/kotlin/world/phantasmal/webui/widgets/ResultDialog.kt index 3f6b7950..626f04f3 100644 --- a/webui/src/main/kotlin/world/phantasmal/webui/widgets/ResultDialog.kt +++ b/webui/src/main/kotlin/world/phantasmal/webui/widgets/ResultDialog.kt @@ -1,6 +1,5 @@ package world.phantasmal.webui.widgets -import kotlinx.coroutines.CoroutineScope import org.w3c.dom.Node import world.phantasmal.core.Failure import world.phantasmal.core.PwResult @@ -16,14 +15,12 @@ import world.phantasmal.webui.dom.ul * button in the footer which triggers [onDismiss]. */ class ResultDialog( - scope: CoroutineScope, visible: Val = trueVal(), enabled: Val = trueVal(), result: Val?>, message: Val, onDismiss: () -> Unit = {}, ) : Dialog( - scope, visible, enabled, title = result.map(::resultToTitle), @@ -33,7 +30,6 @@ class ResultDialog( ) { override fun addFooterContent(footer: Node) { footer.addChild(Button( - scope, visible, enabled, text = "Dismiss", diff --git a/webui/src/main/kotlin/world/phantasmal/webui/widgets/Select.kt b/webui/src/main/kotlin/world/phantasmal/webui/widgets/Select.kt index c893a1c3..96ab4a59 100644 --- a/webui/src/main/kotlin/world/phantasmal/webui/widgets/Select.kt +++ b/webui/src/main/kotlin/world/phantasmal/webui/widgets/Select.kt @@ -1,6 +1,5 @@ package world.phantasmal.webui.widgets -import kotlinx.coroutines.CoroutineScope import org.w3c.dom.Node import org.w3c.dom.events.KeyboardEvent import org.w3c.dom.events.MouseEvent @@ -9,7 +8,6 @@ import world.phantasmal.webui.dom.Icon import world.phantasmal.webui.dom.div class Select( - scope: CoroutineScope, visible: Val = trueVal(), enabled: Val = trueVal(), tooltip: Val = nullVal(), @@ -23,7 +21,6 @@ class Select( selectedVal: Val? = null, private val onSelect: (T) -> Unit = {}, ) : LabelledControl( - scope, visible, enabled, tooltip, @@ -48,7 +45,6 @@ class Select( observe(selected) { buttonText.value = it?.let(itemToString) ?: " " } addWidget(Button( - scope, enabled = enabled, textVal = buttonText, iconRight = Icon.TriangleDown, @@ -57,7 +53,6 @@ class Select( onKeyDown = ::onButtonKeyDown, )) menu = addWidget(Menu( - scope, visible = menuVisible, enabled = enabled, itemsVal = items, diff --git a/webui/src/main/kotlin/world/phantasmal/webui/widgets/TabContainer.kt b/webui/src/main/kotlin/world/phantasmal/webui/widgets/TabContainer.kt index af2d4af3..73883605 100644 --- a/webui/src/main/kotlin/world/phantasmal/webui/widgets/TabContainer.kt +++ b/webui/src/main/kotlin/world/phantasmal/webui/widgets/TabContainer.kt @@ -1,6 +1,5 @@ package world.phantasmal.webui.widgets -import kotlinx.coroutines.CoroutineScope import org.w3c.dom.Node import world.phantasmal.observable.value.Val import world.phantasmal.observable.value.eq @@ -11,12 +10,11 @@ import world.phantasmal.webui.dom.div import world.phantasmal.webui.dom.span class TabContainer( - scope: CoroutineScope, visible: Val = trueVal(), enabled: Val = trueVal(), private val ctrl: TabController, - private val createWidget: (CoroutineScope, T) -> Widget, -) : Widget(scope, visible, enabled) { + private val createWidget: (T) -> Widget, +) : Widget(visible, enabled) { override fun Node.createElement() = div { className = "pw-tab-container" @@ -48,9 +46,8 @@ class TabContainer( for (tab in ctrl.tabs) { addChild( LazyLoader( - scope, visible = ctrl.activeTab eq tab, - createWidget = { scope -> createWidget(scope, tab) } + createWidget = { createWidget(tab) } ) ) } diff --git a/webui/src/main/kotlin/world/phantasmal/webui/widgets/TextArea.kt b/webui/src/main/kotlin/world/phantasmal/webui/widgets/TextArea.kt index 1ca3fda9..4be49c20 100644 --- a/webui/src/main/kotlin/world/phantasmal/webui/widgets/TextArea.kt +++ b/webui/src/main/kotlin/world/phantasmal/webui/widgets/TextArea.kt @@ -1,13 +1,11 @@ package world.phantasmal.webui.widgets -import kotlinx.coroutines.CoroutineScope import org.w3c.dom.Node import world.phantasmal.observable.value.* import world.phantasmal.webui.dom.div import world.phantasmal.webui.dom.textarea class TextArea( - scope: CoroutineScope, visible: Val = trueVal(), enabled: Val = trueVal(), tooltip: Val = nullVal(), @@ -21,7 +19,6 @@ class TextArea( private val rows: Int? = null, private val cols: Int? = null, ) : LabelledControl( - scope, visible, enabled, tooltip, diff --git a/webui/src/main/kotlin/world/phantasmal/webui/widgets/TextInput.kt b/webui/src/main/kotlin/world/phantasmal/webui/widgets/TextInput.kt index 8b6f9aa5..85ad1e7c 100644 --- a/webui/src/main/kotlin/world/phantasmal/webui/widgets/TextInput.kt +++ b/webui/src/main/kotlin/world/phantasmal/webui/widgets/TextInput.kt @@ -1,11 +1,12 @@ package world.phantasmal.webui.widgets -import kotlinx.coroutines.CoroutineScope import org.w3c.dom.HTMLInputElement -import world.phantasmal.observable.value.* +import world.phantasmal.observable.value.Val +import world.phantasmal.observable.value.emptyStringVal +import world.phantasmal.observable.value.nullVal +import world.phantasmal.observable.value.trueVal class TextInput( - scope: CoroutineScope, visible: Val = trueVal(), enabled: Val = trueVal(), tooltip: Val = nullVal(), @@ -16,7 +17,6 @@ class TextInput( onChange: (String) -> Unit = {}, maxLength: Int? = null, ) : Input( - scope, visible, enabled, tooltip, diff --git a/webui/src/main/kotlin/world/phantasmal/webui/widgets/Toolbar.kt b/webui/src/main/kotlin/world/phantasmal/webui/widgets/Toolbar.kt index 48d31d71..75965245 100644 --- a/webui/src/main/kotlin/world/phantasmal/webui/widgets/Toolbar.kt +++ b/webui/src/main/kotlin/world/phantasmal/webui/widgets/Toolbar.kt @@ -1,17 +1,15 @@ package world.phantasmal.webui.widgets -import kotlinx.coroutines.CoroutineScope import org.w3c.dom.Node import world.phantasmal.observable.value.Val import world.phantasmal.observable.value.trueVal import world.phantasmal.webui.dom.div class Toolbar( - scope: CoroutineScope, visible: Val = trueVal(), enabled: Val = trueVal(), children: List, -) : Widget(scope, visible, enabled) { +) : Widget(visible, enabled) { private val childWidgets = children override fun Node.createElement() = diff --git a/webui/src/main/kotlin/world/phantasmal/webui/widgets/Widget.kt b/webui/src/main/kotlin/world/phantasmal/webui/widgets/Widget.kt index d97cd9e1..920e69fc 100644 --- a/webui/src/main/kotlin/world/phantasmal/webui/widgets/Widget.kt +++ b/webui/src/main/kotlin/world/phantasmal/webui/widgets/Widget.kt @@ -2,8 +2,11 @@ package world.phantasmal.webui.widgets import kotlinx.browser.document import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.MainScope +import kotlinx.coroutines.cancel import org.w3c.dom.* import org.w3c.dom.pointerevents.PointerEvent +import world.phantasmal.core.disposable.Disposer import world.phantasmal.observable.Observable import world.phantasmal.observable.value.* import world.phantasmal.observable.value.list.ListVal @@ -15,7 +18,6 @@ import world.phantasmal.webui.dom.disposablePointerDrag import world.phantasmal.webui.dom.documentFragment abstract class Widget( - protected val scope: CoroutineScope, /** * By default determines the hidden attribute of its [element]. */ @@ -63,6 +65,8 @@ abstract class Widget( el } + protected val scope: CoroutineScope = MainScope() + /** * This widget's outermost DOM element. */ @@ -102,6 +106,7 @@ abstract class Widget( } _children.clear() + scope.cancel("Widget disposed.") super.internalDispose() } @@ -125,8 +130,11 @@ abstract class Widget( /** * Adds a child widget to [children] and appends its element to the receiving node. */ - protected fun Node.addChild(child: T): T { - addDisposable(child) + protected fun Node.addChild(child: T, addToDisposer: Boolean = true): T { + if (addToDisposer) { + addDisposable(child) + } + _children.add(child) setAncestorVisible(child, selfOrAncestorVisible.value) appendChild(child.element) @@ -135,7 +143,7 @@ abstract class Widget( protected fun Element.bindChildrenTo( list: Val>, - createChild: Node.(T, Int) -> Node, + createChild: Node.(T, index: Int) -> Node, ) { if (list is ListVal) { bindChildrenTo(list, createChild) @@ -156,7 +164,7 @@ abstract class Widget( protected fun Element.bindChildrenTo( list: ListVal, - createChild: Node.(T, Int) -> Node, + createChild: Node.(T, index: Int) -> Node, ) { fun spliceChildren(index: Int, removedCount: Int, inserted: List) { for (i in 1..removedCount) { @@ -192,6 +200,74 @@ abstract class Widget( spliceChildren(0, 0, list.value) } + protected fun Element.bindChildWidgetsTo( + list: Val>, + createChild: (T, index: Int) -> Widget, + ) { + val disposer = addDisposable(Disposer()) + + if (list is ListVal) { + bindChildWidgetsTo(list, createChild) + } else { + observe(list) { items -> + innerHTML = "" + disposer.disposeAll() + + val frag = document.createDocumentFragment() + + items.forEachIndexed { i, item -> + val child = disposer.add(createChild(item, i)) + frag.addChild(child, addToDisposer = false) + } + + appendChild(frag) + } + } + } + + protected fun Element.bindChildWidgetsTo( + list: ListVal, + createChild: (T, index: Int) -> Widget, + ) { + val disposer = addDisposable(Disposer()) + + fun spliceChildren(index: Int, removedCount: Int, inserted: List) { + for (i in 1..removedCount) { + removeChild(childNodes[index].unsafeCast()) + } + + disposer.removeAt(index, removedCount) + + val frag = document.createDocumentFragment() + + inserted.forEachIndexed { i, value -> + val child = addDisposable(createChild(value, index + i)) + frag.addChild(child, addToDisposer = false) + } + + if (index >= childNodes.length) { + appendChild(frag) + } else { + insertBefore(frag, childNodes[index]) + } + } + + addDisposable( + list.observeList { change: ListValChangeEvent -> + when (change) { + is ListValChangeEvent.Change -> { + spliceChildren(change.index, change.removed.size, change.inserted) + } + is ListValChangeEvent.ElementChange -> { + // TODO: Update children. + } + } + } + ) + + spliceChildren(0, 0, list.value) + } + fun Element.onDrag( onPointerDown: (e: PointerEvent) -> Boolean, onPointerMove: (movedX: Int, movedY: Int, e: PointerEvent) -> Boolean, diff --git a/webui/src/test/kotlin/world/phantasmal/webui/widgets/WidgetTests.kt b/webui/src/test/kotlin/world/phantasmal/webui/widgets/WidgetTests.kt index bad2d25c..18840d94 100644 --- a/webui/src/test/kotlin/world/phantasmal/webui/widgets/WidgetTests.kt +++ b/webui/src/test/kotlin/world/phantasmal/webui/widgets/WidgetTests.kt @@ -1,6 +1,5 @@ package world.phantasmal.webui.widgets -import kotlinx.coroutines.CoroutineScope import org.w3c.dom.Node import world.phantasmal.observable.value.Val import world.phantasmal.observable.value.falseVal @@ -17,9 +16,9 @@ class WidgetTests : WebuiTestSuite() { fun ancestorVisible_and_selfOrAncestorVisible_should_update_when_visible_changes() = test { val parentVisible = mutableVal(true) val childVisible = mutableVal(true) - val grandChild = DummyWidget(scope) - val child = DummyWidget(scope, childVisible, grandChild) - val parent = disposer.add(DummyWidget(scope, parentVisible, child)) + val grandChild = DummyWidget() + val child = DummyWidget(childVisible, grandChild) + val parent = disposer.add(DummyWidget(parentVisible, child)) parent.element // Ensure widgets are fully initialized. @@ -53,8 +52,8 @@ class WidgetTests : WebuiTestSuite() { @Test fun added_child_widgets_should_have_ancestorVisible_and_selfOrAncestorVisible_set_correctly() = test { - val parent = disposer.add(DummyWidget(scope, visible = falseVal())) - val child = parent.addChild(DummyWidget(scope)) + val parent = disposer.add(DummyWidget(visible = falseVal())) + val child = parent.addChild(DummyWidget()) assertTrue(parent.ancestorVisible.value) assertFalse(parent.selfOrAncestorVisible.value) @@ -63,10 +62,9 @@ class WidgetTests : WebuiTestSuite() { } private inner class DummyWidget( - scope: CoroutineScope, visible: Val = trueVal(), private val child: Widget? = null, - ) : Widget(scope, visible = visible) { + ) : Widget(visible = visible) { override fun Node.createElement() = div { child?.let { addChild(it) } }