Entities can be dragged and dropped again.

This commit is contained in:
Daan Vanden Bosch 2020-12-02 21:03:42 +01:00
parent 0c0d6355f2
commit 515cba5555
108 changed files with 1567 additions and 633 deletions

View File

@ -70,39 +70,38 @@ private class Assembler(private val assembly: List<String>, private val inlineSt
var hasLabel = false var hasLabel = false
when (token) { when (token) {
is LabelToken -> { is Token.Label -> {
parseLabel(token) parseLabel(token)
hasLabel = true hasLabel = true
} }
is SectionToken, is Token.Section -> {
-> {
parseSection(token) parseSection(token)
} }
is IntToken -> { is Token.Int32 -> {
if (section == SegmentType.Data) { if (section == SegmentType.Data) {
parseBytes(token) parseBytes(token)
} else { } else {
addUnexpectedTokenError(token) addUnexpectedTokenError(token)
} }
} }
is StringToken -> { is Token.Str -> {
if (section == SegmentType.String) { if (section == SegmentType.String) {
parseString(token) parseString(token)
} else { } else {
addUnexpectedTokenError(token) addUnexpectedTokenError(token)
} }
} }
is IdentToken -> { is Token.Ident -> {
if (section === SegmentType.Instructions) { if (section === SegmentType.Instructions) {
parseInstruction(token) parseInstruction(token)
} else { } else {
addUnexpectedTokenError(token) addUnexpectedTokenError(token)
} }
} }
is InvalidSectionToken -> { is Token.InvalidSection -> {
addError(token, "Invalid section type.") addError(token, "Invalid section type.")
} }
is InvalidIdentToken -> { is Token.InvalidIdent -> {
addError(token, "Invalid identifier.") addError(token, "Invalid identifier.")
} }
else -> { else -> {
@ -234,9 +233,11 @@ private class Assembler(private val assembly: List<String>, private val inlineSt
} }
private fun addUnexpectedTokenError(token: Token) { private fun addUnexpectedTokenError(token: Token) {
addError(token, addError(
token,
"Unexpected token.", "Unexpected token.",
"Unexpected ${token::class.simpleName} at ${token.srcLoc()}.") "Unexpected ${token::class.simpleName} at ${token.srcLoc()}.",
)
} }
private fun addWarning(token: Token, uiMessage: String) { private fun addWarning(token: Token, uiMessage: String) {
@ -246,12 +247,12 @@ private class Assembler(private val assembly: List<String>, private val inlineSt
uiMessage, uiMessage,
lineNo = lineNo, lineNo = lineNo,
col = token.col, col = token.col,
length = token.len length = token.len,
) )
) )
} }
private fun parseLabel(token: LabelToken) { private fun parseLabel(token: Token.Label) {
val label = token.value val label = token.value
if (!labels.add(label)) { if (!labels.add(label)) {
@ -281,7 +282,7 @@ private class Assembler(private val assembly: List<String>, private val inlineSt
} }
if (nextToken != null) { if (nextToken != null) {
if (nextToken is IdentToken) { if (nextToken is Token.Ident) {
parseInstruction(nextToken) parseInstruction(nextToken)
} else { } else {
addError(nextToken, "Expected opcode mnemonic.") addError(nextToken, "Expected opcode mnemonic.")
@ -300,7 +301,7 @@ private class Assembler(private val assembly: List<String>, private val inlineSt
} }
if (nextToken != null) { if (nextToken != null) {
if (nextToken is IntToken) { if (nextToken is Token.Int32) {
parseBytes(nextToken) parseBytes(nextToken)
} else { } else {
addError(nextToken, "Expected bytes.") addError(nextToken, "Expected bytes.")
@ -319,7 +320,7 @@ private class Assembler(private val assembly: List<String>, private val inlineSt
} }
if (nextToken != null) { if (nextToken != null) {
if (nextToken is StringToken) { if (nextToken is Token.Str) {
parseString(nextToken) parseString(nextToken)
} else { } else {
addError(nextToken, "Expected a string.") addError(nextToken, "Expected a string.")
@ -329,11 +330,11 @@ private class Assembler(private val assembly: List<String>, private val inlineSt
} }
} }
private fun parseSection(token: SectionToken) { private fun parseSection(token: Token.Section) {
val section = when (token) { val section = when (token) {
is CodeSectionToken -> SegmentType.Instructions is Token.Section.Code -> SegmentType.Instructions
is DataSectionToken -> SegmentType.Data is Token.Section.Data -> SegmentType.Data
is StringSectionToken -> SegmentType.String is Token.Section.Str -> SegmentType.String
} }
if (this.section == section && !firstSectionMarker) { if (this.section == section && !firstSectionMarker) {
@ -348,11 +349,11 @@ private class Assembler(private val assembly: List<String>, private val inlineSt
} }
} }
private fun parseInstruction(identToken: IdentToken) { private fun parseInstruction(identToken: Token.Ident) {
val opcode = mnemonicToOpcode(identToken.value) val opcode = mnemonicToOpcode(identToken.value)
if (opcode == null) { if (opcode == null) {
addError(identToken, "Unknown instruction.") addError(identToken, "Unknown opcode.")
} else { } else {
val varargs = opcode.params.any { val varargs = opcode.params.any {
it.type is ILabelVarType || it.type is RegRefVarType it.type is ILabelVarType || it.type is RegRefVarType
@ -362,7 +363,7 @@ private class Assembler(private val assembly: List<String>, private val inlineSt
if (!inlineStackArgs && opcode.stack == StackInteraction.Pop) 0 if (!inlineStackArgs && opcode.stack == StackInteraction.Pop) 0
else opcode.params.size else opcode.params.size
val argCount = tokens.count { it !is ArgSeparatorToken } val argCount = tokens.count { it !is Token.ArgSeparator }
val lastToken = tokens.lastOrNull() val lastToken = tokens.lastOrNull()
val errorLength = lastToken?.let { it.col + it.len - identToken.col } ?: 0 val errorLength = lastToken?.let { it.col + it.len - identToken.col } ?: 0
@ -375,11 +376,13 @@ private class Assembler(private val assembly: List<String>, private val inlineSt
addError( addError(
identToken.col, identToken.col,
errorLength, errorLength,
"Expected $paramCount argument ${if (paramCount == 1) "" else "s"}, got $argCount." "Expected $paramCount argument ${if (paramCount == 1) "" else "s"}, got $argCount.",
) )
return return
} else if (varargs && argCount < paramCount) { } else if (varargs && argCount < paramCount) {
// TODO: This check assumes we want at least 1 argument for a vararg parameter.
// Is this correct?
addError( addError(
identToken.col, identToken.col,
errorLength, errorLength,
@ -388,12 +391,13 @@ private class Assembler(private val assembly: List<String>, private val inlineSt
return return
} else if (opcode.stack !== StackInteraction.Pop) { } else if (opcode.stack !== StackInteraction.Pop) {
// Inline arguments. // Arguments should be inlined right after the opcode.
if (!parseArgs(opcode.params, insArgAndTokens, stack = false)) { if (!parseArgs(opcode.params, insArgAndTokens, stack = false)) {
return return
} }
} else { } 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 return
} }
@ -402,7 +406,7 @@ private class Assembler(private val assembly: List<String>, private val inlineSt
val argAndToken = stackArgAndTokens.getOrNull(i) ?: continue val argAndToken = stackArgAndTokens.getOrNull(i) ?: continue
val (arg, argToken) = argAndToken val (arg, argToken) = argAndToken
if (argToken is RegisterToken) { if (argToken is Token.Register) {
if (param.type is RegTupRefType) { if (param.type is RegTupRefType) {
addInstruction( addInstruction(
OP_ARG_PUSHB, OP_ARG_PUSHB,
@ -527,7 +531,7 @@ private class Assembler(private val assembly: List<String>, private val inlineSt
val token = tokens[i] val token = tokens[i]
val param = params[paramI] val param = params[paramI]
if (token is ArgSeparatorToken) { if (token is Token.ArgSeparator) {
if (shouldBeArg) { if (shouldBeArg) {
addError(token, "Expected an argument.") addError(token, "Expected an argument.")
} else if ( } else if (
@ -551,7 +555,7 @@ private class Assembler(private val assembly: List<String>, private val inlineSt
var match: Boolean var match: Boolean
when (token) { when (token) {
is IntToken -> { is Token.Int32 -> {
when (param.type) { when (param.type) {
is ByteType -> { is ByteType -> {
match = true match = true
@ -581,7 +585,7 @@ private class Assembler(private val assembly: List<String>, private val inlineSt
} }
} }
is FloatToken -> { is Token.Float32 -> {
match = param.type == FloatType match = param.type == FloatType
if (match) { if (match) {
@ -589,7 +593,7 @@ private class Assembler(private val assembly: List<String>, private val inlineSt
} }
} }
is RegisterToken -> { is Token.Register -> {
match = stack || match = stack ||
param.type is RegRefType || param.type is RegRefType ||
param.type is RegRefVarType || param.type is RegRefVarType ||
@ -598,7 +602,7 @@ private class Assembler(private val assembly: List<String>, private val inlineSt
parseRegister(token, argAndTokens) parseRegister(token, argAndTokens)
} }
is StringToken -> { is Token.Str -> {
match = param.type is StringType match = param.type is StringType
if (match) { if (match) {
@ -649,7 +653,11 @@ private class Assembler(private val assembly: List<String>, private val inlineSt
return semiValid return semiValid
} }
private fun parseInt(size: Int, token: IntToken, argAndTokens: MutableList<Pair<Arg, Token>>) { private fun parseInt(
size: Int,
token: Token.Int32,
argAndTokens: MutableList<Pair<Arg, Token>>,
) {
val value = token.value val value = token.value
val bitSize = 8 * size val bitSize = 8 * size
// Minimum of the signed version of this integer type. // Minimum of the signed version of this integer type.
@ -670,7 +678,7 @@ private class Assembler(private val assembly: List<String>, private val inlineSt
} }
} }
private fun parseRegister(token: RegisterToken, argAndTokens: MutableList<Pair<Arg, Token>>) { private fun parseRegister(token: Token.Register, argAndTokens: MutableList<Pair<Arg, Token>>) {
val value = token.value val value = token.value
if (value > 255) { if (value > 255) {
@ -680,12 +688,12 @@ private class Assembler(private val assembly: List<String>, private val inlineSt
} }
} }
private fun parseBytes(firstToken: IntToken) { private fun parseBytes(firstToken: Token.Int32) {
val bytes = mutableListOf<Byte>() val bytes = mutableListOf<Byte>()
var token: Token = firstToken var token: Token = firstToken
var i = 0 var i = 0
while (token is IntToken) { while (token is Token.Int32) {
if (token.value < 0) { if (token.value < 0) {
addError(token, "Unsigned 8-bit integer can't be less than 0.") addError(token, "Unsigned 8-bit integer can't be less than 0.")
} else if (token.value > 255) { } else if (token.value > 255) {
@ -708,7 +716,7 @@ private class Assembler(private val assembly: List<String>, private val inlineSt
addBytes(bytes.toByteArray()) addBytes(bytes.toByteArray())
} }
private fun parseString(token: StringToken) { private fun parseString(token: Token.Str) {
tokens.removeFirstOrNull()?.let { nextToken -> tokens.removeFirstOrNull()?.let { nextToken ->
addUnexpectedTokenError(nextToken) addUnexpectedTokenError(nextToken)
} }

View File

@ -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 FLOAT_REGEX = Regex("""^-?\d+(\.\d+)?(e-?\d+)?$""")
private val IDENT_REGEX = Regex("""^[a-z][a-z0-9_=<>!]*$""") private val IDENT_REGEX = Regex("""^[a-z][a-z0-9_=<>!]*$""")
sealed class Token( sealed class Token {
val col: Int, abstract val col: Int
val len: Int, abstract val len: Int
)
class IntToken( class Int32(
col: Int, override val col: Int,
len: Int, override val len: Int,
val value: Int, val value: Int,
) : Token(col, len) ) : Token()
class FloatToken( class Float32(
col: Int, override val col: Int,
len: Int, override val len: Int,
val value: Float, val value: Float,
) : Token(col, len) ) : Token()
class InvalidNumberToken( class InvalidNumber(
col: Int, override val col: Int,
len: Int, override val len: Int,
) : Token(col, len) ) : Token()
class RegisterToken( class Register(
col: Int, override val col: Int,
len: Int, override val len: Int,
val value: Int, val value: Int,
) : Token(col, len) ) : Token()
class LabelToken( class Label(
col: Int, override val col: Int,
len: Int, override val len: Int,
val value: Int, val value: Int,
) : Token(col, len) ) : 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( class Data(
col: Int, override val col: Int,
len: Int, override val len: Int,
) : SectionToken(col, len) ) : Section()
class DataSectionToken( class Str(
col: Int, override val col: Int,
len: Int, override val len: Int,
) : SectionToken(col, len) ) : Section()
}
class StringSectionToken( class InvalidSection(
col: Int, override val col: Int,
len: Int, override val len: Int,
) : SectionToken(col, len) ) : Token()
class InvalidSectionToken( class Str(
col: Int, override val col: Int,
len: Int, override val len: Int,
) : Token(col, len) val value: String,
) : Token()
class StringToken( class UnterminatedString(
col: Int, override val col: Int,
len: Int, override val len: Int,
val value: String, val value: String,
) : Token(col, len) ) : Token()
class UnterminatedStringToken( class Ident(
col: Int, override val col: Int,
len: Int, override val len: Int,
val value: String, val value: String,
) : Token(col, len) ) : Token()
class IdentToken( class InvalidIdent(
col: Int, override val col: Int,
len: Int, override val len: Int,
val value: String, ) : Token()
) : Token(col, len)
class InvalidIdentToken( class ArgSeparator(
col: Int, override val col: Int,
len: Int, override val len: Int,
) : Token(col, len) ) : Token()
}
class ArgSeparatorToken(
col: Int,
len: Int,
) : Token(col, len)
fun tokenizeLine(line: String): MutableList<Token> = fun tokenizeLine(line: String): MutableList<Token> =
LineTokenizer(line).tokenize() LineTokenizer(line).tokenize()
@ -125,7 +125,7 @@ private class LineTokenizer(private var line: String) {
} else if (char == '-' || char.isDigit()) { } else if (char == '-' || char.isDigit()) {
token = tokenizeNumberOrLabel() token = tokenizeNumberOrLabel()
} else if (char == ',') { } else if (char == ',') {
token = ArgSeparatorToken(col, 1) token = Token.ArgSeparator(col, 1)
skip() skip()
} else if (char == '.') { } else if (char == '.') {
token = tokenizeSection() token = tokenizeSection()
@ -206,13 +206,13 @@ private class LineTokenizer(private var line: String) {
} }
if (value == null) { if (value == null) {
return InvalidNumberToken(col, markedLen()) return Token.InvalidNumber(col, markedLen())
} }
return if (isLabel) { return if (isLabel) {
LabelToken(col, markedLen(), value) Token.Label(col, markedLen(), value)
} else { } 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)) { if (HEX_INT_REGEX.matches(hexStr)) {
hexStr.toIntOrNull(16)?.let { value -> 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 { private fun tokenizeFloat(col: Int): Token {
@ -235,11 +235,11 @@ private class LineTokenizer(private var line: String) {
if (FLOAT_REGEX.matches(floatStr)) { if (FLOAT_REGEX.matches(floatStr)) {
floatStr.toFloatOrNull()?.let { value -> 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 { private fun tokenizeRegisterOrIdent(): Token {
@ -262,7 +262,7 @@ private class LineTokenizer(private var line: String) {
return if (isRegister) { return if (isRegister) {
val value = slice().toInt() val value = slice().toInt()
RegisterToken(col, markedLen() + 1, value) Token.Register(col, markedLen() + 1, value)
} else { } else {
back() back()
tokenizeIdent() tokenizeIdent()
@ -282,10 +282,10 @@ private class LineTokenizer(private var line: String) {
} }
return when (slice()) { return when (slice()) {
".code" -> CodeSectionToken(col, 5) ".code" -> Token.Section.Code(col, 5)
".data" -> DataSectionToken(col, 5) ".data" -> Token.Section.Data(col, 5)
".string" -> StringSectionToken(col, 7) ".string" -> Token.Section.Str(col, 7)
else -> InvalidSectionToken(col, markedLen()) else -> Token.InvalidSection(col, markedLen())
} }
} }
@ -321,9 +321,9 @@ private class LineTokenizer(private var line: String) {
return if (terminated) { return if (terminated) {
next() next()
StringToken(col, markedLen() + 2, value) Token.Str(col, markedLen() + 2, value)
} else { } 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() val value = slice()
return if (IDENT_REGEX.matches(value)) { return if (IDENT_REGEX.matches(value)) {
IdentToken(col, markedLen(), value) Token.Ident(col, markedLen(), value)
} else { } else {
InvalidIdentToken(col, markedLen()) Token.InvalidIdent(col, markedLen())
} }
} }
} }

View File

@ -14,8 +14,8 @@ protected constructor(protected val offset: Int) : WritableCursor {
protected val absolutePosition: Int protected val absolutePosition: Int
get() = offset + position get() = offset + position
override fun hasBytesLeft(atLeast: Int): Boolean = override fun hasBytesLeft(): Boolean =
bytesLeft >= atLeast bytesLeft > 0
override fun seek(offset: Int): WritableCursor = override fun seek(offset: Int): WritableCursor =
seekStart(position + offset) seekStart(position + offset)

View File

@ -21,7 +21,7 @@ interface Cursor {
val bytesLeft: Int val bytesLeft: Int
fun hasBytesLeft(atLeast: Int = 1): Boolean fun hasBytesLeft(): Boolean
/** /**
* Seek forward or backward by a number of bytes. * Seek forward or backward by a number of bytes.

View File

@ -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<List<Buffer>> {
val result = PwResult.build<List<Buffer>>(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<Buffer>()
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)
}

View File

@ -1,10 +1,13 @@
package world.phantasmal.lib.fileFormats.quest package world.phantasmal.lib.fileFormats.quest
interface EntityType { interface EntityType {
val name: String
/** /**
* Unique name. E.g. an episode II Delsaber would have (Ep. II) appended to its name. * Unique name. E.g. an episode II Delsaber would have (Ep. II) appended to its name.
*/ */
val uniqueName: String val uniqueName: String
/** /**
* Name used in the game. * Name used in the game.
* Might conflict with other NPC names (e.g. Delsaber from ep. I and ep. II). * Might conflict with other NPC names (e.g. Delsaber from ep. I and ep. II).

View File

@ -285,7 +285,7 @@ private fun parseFiles(
} }
} }
while (cursor.hasBytesLeft(chunkSize)) { while (cursor.bytesLeft >= chunkSize) {
val startPosition = cursor.position val startPosition = cursor.position
// Read chunk header. // Read chunk header.

View File

@ -7,6 +7,11 @@ import world.phantasmal.lib.fileFormats.ninja.radToAngle
import kotlin.math.roundToInt import kotlin.math.roundToInt
class QuestObject(override var areaId: Int, val data: Buffer) : QuestEntity<ObjectType> { class QuestObject(override var areaId: Int, val data: Buffer) : QuestEntity<ObjectType> {
constructor(type: ObjectType, areaId: Int) : this(areaId, Buffer.withSize(OBJECT_BYTE_SIZE)) {
// TODO: Set default data.
this.type = type
}
var typeId: Int var typeId: Int
get() = data.getShort(0).toInt() get() = data.getShort(0).toInt()
set(value) { set(value) {

View File

@ -7,40 +7,40 @@ import kotlin.test.assertEquals
class AssemblyTokenizationTests : LibTestSuite() { class AssemblyTokenizationTests : LibTestSuite() {
@Test @Test
fun valid_floats_are_parsed_as_FloatTokens() { fun valid_floats_are_parsed_as_Float32_tokens() {
assertCloseTo(808.9f, (tokenizeLine("808.9")[0] as FloatToken).value) assertCloseTo(808.9f, (tokenizeLine("808.9")[0] as Token.Float32).value)
assertCloseTo(-0.9f, (tokenizeLine("-0.9")[0] as FloatToken).value) assertCloseTo(-0.9f, (tokenizeLine("-0.9")[0] as Token.Float32).value)
assertCloseTo(0.001f, (tokenizeLine("1e-3")[0] as FloatToken).value) assertCloseTo(0.001f, (tokenizeLine("1e-3")[0] as Token.Float32).value)
assertCloseTo(-600.0f, (tokenizeLine("-6e2")[0] as FloatToken).value) assertCloseTo(-600.0f, (tokenizeLine("-6e2")[0] as Token.Float32).value)
} }
@Test @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 ") val tokens1 = tokenizeLine(" 808.9a ")
assertEquals(1, tokens1.size) assertEquals(1, tokens1.size)
assertEquals(InvalidNumberToken::class, tokens1[0]::class) assertEquals(Token.InvalidNumber::class, tokens1[0]::class)
assertEquals(2, tokens1[0].col) assertEquals(2, tokens1[0].col)
assertEquals(6, tokens1[0].len) assertEquals(6, tokens1[0].len)
val tokens2 = tokenizeLine(" -55e ") val tokens2 = tokenizeLine(" -55e ")
assertEquals(1, tokens2.size) assertEquals(1, tokens2.size)
assertEquals(InvalidNumberToken::class, tokens2[0]::class) assertEquals(Token.InvalidNumber::class, tokens2[0]::class)
assertEquals(3, tokens2[0].col) assertEquals(3, tokens2[0].col)
assertEquals(4, tokens2[0].len) assertEquals(4, tokens2[0].len)
val tokens3 = tokenizeLine(".7429") val tokens3 = tokenizeLine(".7429")
assertEquals(1, tokens3.size) assertEquals(1, tokens3.size)
assertEquals(InvalidSectionToken::class, tokens3[0]::class) assertEquals(Token.InvalidSection::class, tokens3[0]::class)
assertEquals(1, tokens3[0].col) assertEquals(1, tokens3[0].col)
assertEquals(5, tokens3[0].len) assertEquals(5, tokens3[0].len)
val tokens4 = tokenizeLine("\t\t\t4. test") val tokens4 = tokenizeLine("\t\t\t4. test")
assertEquals(2, tokens4.size) assertEquals(2, tokens4.size)
assertEquals(InvalidNumberToken::class, tokens4[0]::class) assertEquals(Token.InvalidNumber::class, tokens4[0]::class)
assertEquals(4, tokens4[0].col) assertEquals(4, tokens4[0].col)
assertEquals(2, tokens4[0].len) assertEquals(2, tokens4[0].len)
} }

View File

@ -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<E>(
private val dependency: ListVal<E>,
private val predicate: (E) -> Boolean,
) : AbstractListVal<E>(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<E>
get() {
if (!hasObservers) {
recompute()
}
return elements
}
override val sizeVal: Val<Int> = _sizeVal
override fun observe(callNow: Boolean, observer: Observer<List<E>>): Disposable {
initDependencyObservers()
val superDisposable = super.observe(callNow, observer)
return disposable {
superDisposable.dispose()
disposeDependencyObservers()
}
}
override fun observeList(callNow: Boolean, observer: ListValObserver<E>): 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<E>()
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<E>) {
if (event is ListValChangeEvent.Change && event.removed.size != event.inserted.size) {
_sizeVal.publicEmit()
}
super.finalizeUpdate(event)
}
private inner class SizeVal : AbstractVal<Int>() {
override val value: Int
get() {
if (!hasObservers) {
recompute()
}
return elements.size
}
val publicObservers = super.observers
override fun observe(callNow: Boolean, observer: Observer<Int>): Disposable {
initDependencyObservers()
val superDisposable = super.observe(callNow, observer)
return disposable {
superDisposable.dispose()
disposeDependencyObservers()
}
}
fun publicEmit() {
super.emit()
}
}
}

View File

@ -17,5 +17,5 @@ interface ListVal<E> : Val<List<E>> {
FoldedVal(this, initialValue, operation) FoldedVal(this, initialValue, operation)
fun filtered(predicate: (E) -> Boolean): ListVal<E> = fun filtered(predicate: (E) -> Boolean): ListVal<E> =
DependentListVal(listOf(this)) { value.filter(predicate) } FilteredListVal(this, predicate)
} }

View File

@ -9,6 +9,8 @@ interface MutableListVal<E> : ListVal<E>, MutableVal<List<E>> {
fun add(index: Int, element: E) fun add(index: Int, element: E)
fun remove(element: E): Boolean
fun removeAt(index: Int): E fun removeAt(index: Int): E
fun replaceAll(elements: Iterable<E>) fun replaceAll(elements: Iterable<E>)

View File

@ -45,6 +45,17 @@ class SimpleListVal<E>(
finalizeUpdate(ListValChangeEvent.Change(index, emptyList(), listOf(element))) 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 { override fun removeAt(index: Int): E {
val removed = elements.removeAt(index) val removed = elements.removeAt(index)
finalizeUpdate(ListValChangeEvent.Change(index, listOf(removed), emptyList())) finalizeUpdate(ListValChangeEvent.Change(index, listOf(removed), emptyList()))

View File

@ -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<Int>(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<Int>(mutableListOf())
val list = FilteredListVal(dep) { it % 2 == 0 }
var event: ListValChangeEvent<Int>? = 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<Int>(mutableListOf())
val list = FilteredListVal(l) { it % 2 == 0 }
return ListValAndAdd(list) { l.add(4) }
}
}

View File

@ -1,11 +1,5 @@
package world.phantasmal.testUtils package world.phantasmal.testUtils
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import world.phantasmal.core.disposable.Disposer import world.phantasmal.core.disposable.Disposer
open class TestContext(val disposer: Disposer) { open class TestContext(val disposer: Disposer)
val scope: CoroutineScope = object : CoroutineScope {
override val coroutineContext = Job()
}
}

View File

@ -5,8 +5,6 @@ import io.ktor.client.features.json.*
import io.ktor.client.features.json.serializer.* import io.ktor.client.features.json.serializer.*
import kotlinx.browser.document import kotlinx.browser.document
import kotlinx.browser.window import kotlinx.browser.window
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel import kotlinx.coroutines.cancel
import kotlinx.datetime.Clock import kotlinx.datetime.Clock
import mu.KotlinLoggingConfiguration import mu.KotlinLoggingConfiguration
@ -58,12 +56,8 @@ private fun init(): Disposable {
} }
disposer.add(disposable { httpClient.cancel() }) disposer.add(disposable { httpClient.cancel() })
val scope = CoroutineScope(SupervisorJob())
disposer.add(disposable { scope.cancel() })
disposer.add( disposer.add(
Application( Application(
scope,
rootElement, rootElement,
AssetLoader(httpClient), AssetLoader(httpClient),
disposer.add(HistoryApplicationUrl()), disposer.add(HistoryApplicationUrl()),
@ -98,7 +92,7 @@ private class HistoryApplicationUrl : TrackedDisposable(), ApplicationUrl {
override val url = mutableVal(window.location.hash.substring(1)) override val url = mutableVal(window.location.hash.substring(1))
private val popStateListener = disposableListener<PopStateEvent>(window, "popstate", { private val popStateListener = window.disposableListener<PopStateEvent>("popstate", {
url.value = window.location.hash.substring(1) url.value = window.location.hash.substring(1)
}) })

View File

@ -1,7 +1,6 @@
package world.phantasmal.web.application package world.phantasmal.web.application
import kotlinx.browser.document import kotlinx.browser.document
import kotlinx.coroutines.CoroutineScope
import kotlinx.datetime.Clock import kotlinx.datetime.Clock
import org.w3c.dom.DragEvent import org.w3c.dom.DragEvent
import org.w3c.dom.HTMLCanvasElement import org.w3c.dom.HTMLCanvasElement
@ -25,7 +24,6 @@ import world.phantasmal.webui.DisposableContainer
import world.phantasmal.webui.dom.disposableListener import world.phantasmal.webui.dom.disposableListener
class Application( class Application(
scope: CoroutineScope,
rootElement: HTMLElement, rootElement: HTMLElement,
assetLoader: AssetLoader, assetLoader: AssetLoader,
applicationUrl: ApplicationUrl, applicationUrl: ApplicationUrl,
@ -35,19 +33,19 @@ class Application(
init { init {
addDisposables( addDisposables(
// Disable native undo/redo. // Disable native undo/redo.
disposableListener(document, "beforeinput", ::beforeInput), document.disposableListener("beforeinput", ::beforeInput),
// Work-around for FireFox: // 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 // Disable native drag-and-drop to avoid users dragging in unsupported file formats and
// leaving the application unexpectedly. // leaving the application unexpectedly.
disposableListener(document, "dragenter", ::dragenter), document.disposableListener("dragenter", ::dragenter),
disposableListener(document, "dragover", ::dragover), document.disposableListener("dragover", ::dragover),
disposableListener(document, "drop", ::drop), document.disposableListener("drop", ::drop),
) )
// Initialize core stores shared by several submodules. // 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. // The various tools Phantasmal World consists of.
val tools: List<PwTool> = listOf( val tools: List<PwTool> = listOf(
@ -63,10 +61,8 @@ class Application(
// Initialize application view. // Initialize application view.
val applicationWidget = addDisposable( val applicationWidget = addDisposable(
ApplicationWidget( ApplicationWidget(
scope, NavigationWidget(navigationController),
NavigationWidget(scope, navigationController),
MainContentWidget( MainContentWidget(
scope,
mainContentController, mainContentController,
tools.map { it.toolType to it::initialize }.toMap() tools.map { it.toolType to it::initialize }.toMap()
) )

View File

@ -1,15 +1,13 @@
package world.phantasmal.web.application.widgets package world.phantasmal.web.application.widgets
import kotlinx.coroutines.CoroutineScope
import org.w3c.dom.Node import org.w3c.dom.Node
import world.phantasmal.webui.dom.div import world.phantasmal.webui.dom.div
import world.phantasmal.webui.widgets.Widget import world.phantasmal.webui.widgets.Widget
class ApplicationWidget( class ApplicationWidget(
scope: CoroutineScope,
private val navigationWidget: NavigationWidget, private val navigationWidget: NavigationWidget,
private val mainContentWidget: MainContentWidget, private val mainContentWidget: MainContentWidget,
) : Widget(scope) { ) : Widget() {
override fun Node.createElement() = override fun Node.createElement() =
div { div {
className = "pw-application-application" className = "pw-application-application"

View File

@ -1,6 +1,5 @@
package world.phantasmal.web.application.widgets package world.phantasmal.web.application.widgets
import kotlinx.coroutines.CoroutineScope
import org.w3c.dom.Node import org.w3c.dom.Node
import world.phantasmal.web.application.controllers.MainContentController import world.phantasmal.web.application.controllers.MainContentController
import world.phantasmal.web.core.PwToolType import world.phantasmal.web.core.PwToolType
@ -9,17 +8,16 @@ import world.phantasmal.webui.widgets.LazyLoader
import world.phantasmal.webui.widgets.Widget import world.phantasmal.webui.widgets.Widget
class MainContentWidget( class MainContentWidget(
scope: CoroutineScope,
private val ctrl: MainContentController, private val ctrl: MainContentController,
private val toolViews: Map<PwToolType, (CoroutineScope) -> Widget>, private val toolViews: Map<PwToolType, () -> Widget>,
) : Widget(scope) { ) : Widget() {
override fun Node.createElement() = override fun Node.createElement() =
div { div {
className = "pw-application-main-content" className = "pw-application-main-content"
ctrl.tools.forEach { (tool, active) -> ctrl.tools.forEach { (tool, active) ->
toolViews[tool]?.let { createWidget -> toolViews[tool]?.let { createWidget ->
addChild(LazyLoader(scope, visible = active, createWidget = createWidget)) addChild(LazyLoader(visible = active, createWidget = createWidget))
} }
} }
} }

View File

@ -1,6 +1,5 @@
package world.phantasmal.web.application.widgets package world.phantasmal.web.application.widgets
import kotlinx.coroutines.CoroutineScope
import org.w3c.dom.Node import org.w3c.dom.Node
import world.phantasmal.observable.value.falseVal import world.phantasmal.observable.value.falseVal
import world.phantasmal.observable.value.value 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.Select
import world.phantasmal.webui.widgets.Widget import world.phantasmal.webui.widgets.Widget
class NavigationWidget( class NavigationWidget(private val ctrl: NavigationController) : Widget() {
scope: CoroutineScope,
private val ctrl: NavigationController,
) : Widget(scope) {
override fun Node.createElement() = override fun Node.createElement() =
div { div {
className = "pw-application-navigation" className = "pw-application-navigation"
ctrl.tools.forEach { (tool, active) -> ctrl.tools.forEach { (tool, active) ->
addChild(PwToolButton(scope, tool, active) { ctrl.setCurrentTool(tool) }) addChild(PwToolButton(tool, active) { ctrl.setCurrentTool(tool) })
} }
div { div {
@ -32,7 +28,6 @@ class NavigationWidget(
className = "pw-application-navigation-right" className = "pw-application-navigation-right"
val serverSelect = Select( val serverSelect = Select(
scope,
enabled = falseVal(), enabled = falseVal(),
label = "Server:", label = "Server:",
items = listOf("Ephinea"), items = listOf("Ephinea"),

View File

@ -1,6 +1,5 @@
package world.phantasmal.web.application.widgets package world.phantasmal.web.application.widgets
import kotlinx.coroutines.CoroutineScope
import org.w3c.dom.Node import org.w3c.dom.Node
import world.phantasmal.observable.Observable import world.phantasmal.observable.Observable
import world.phantasmal.web.core.PwToolType import world.phantasmal.web.core.PwToolType
@ -10,11 +9,10 @@ import world.phantasmal.webui.dom.span
import world.phantasmal.webui.widgets.Control import world.phantasmal.webui.widgets.Control
class PwToolButton( class PwToolButton(
scope: CoroutineScope,
private val tool: PwToolType, private val tool: PwToolType,
private val toggled: Observable<Boolean>, private val toggled: Observable<Boolean>,
private val mouseDown: () -> Unit, private val mouseDown: () -> Unit,
) : Control(scope) { ) : Control() {
private val inputId = "pw-application-pw-tool-button-${tool.name.toLowerCase()}" private val inputId = "pw-application-pw-tool-button-${tool.name.toLowerCase()}"
override fun Node.createElement() = override fun Node.createElement() =

View File

@ -1,6 +1,5 @@
package world.phantasmal.web.core package world.phantasmal.web.core
import kotlinx.coroutines.CoroutineScope
import world.phantasmal.webui.widgets.Widget import world.phantasmal.webui.widgets.Widget
/** /**
@ -14,5 +13,5 @@ interface PwTool {
/** /**
* The caller of this method takes ownership of the returned widget. * The caller of this method takes ownership of the returned widget.
*/ */
fun initialize(scope: CoroutineScope): Widget fun initialize(): Widget
} }

View File

@ -21,6 +21,10 @@ operator fun Vector3.minusAssign(other: Vector3) {
operator fun Vector3.times(scalar: Double): Vector3 = operator fun Vector3.times(scalar: Double): Vector3 =
clone().multiplyScalar(scalar) clone().multiplyScalar(scalar)
operator fun Vector3.timesAssign(scalar: Double) {
multiplyScalar(scalar)
}
infix fun Vector3.dot(other: Vector3): Double = infix fun Vector3.dot(other: Vector3): Double =
dot(other) dot(other)

View File

@ -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
}

View File

@ -13,6 +13,7 @@ class OrbitalCameraInputManager(
private val camera: Camera, private val camera: Camera,
position: Vector3, position: Vector3,
screenSpacePanning: Boolean, screenSpacePanning: Boolean,
enableRotate: Boolean = true,
) : TrackedDisposable(), InputManager { ) : TrackedDisposable(), InputManager {
private val controls = OrbitControls(camera, canvas) private val controls = OrbitControls(camera, canvas)
@ -31,6 +32,7 @@ class OrbitalCameraInputManager(
camera.position.copy(position) camera.position.copy(position)
controls.screenSpacePanning = screenSpacePanning controls.screenSpacePanning = screenSpacePanning
controls.enableRotate = enableRotate
controls.update() controls.update()
controls.saveState() controls.saveState()
} }

View File

@ -4,17 +4,12 @@ import kotlinx.browser.document
import kotlinx.browser.window import kotlinx.browser.window
import mu.KotlinLogging import mu.KotlinLogging
import org.w3c.dom.HTMLCanvasElement import org.w3c.dom.HTMLCanvasElement
import world.phantasmal.core.disposable.Disposable
import world.phantasmal.webui.DisposableContainer import world.phantasmal.webui.DisposableContainer
import kotlin.math.floor import kotlin.math.floor
import world.phantasmal.web.externals.three.Renderer as ThreeRenderer import world.phantasmal.web.externals.three.Renderer as ThreeRenderer
private val logger = KotlinLogging.logger {} private val logger = KotlinLogging.logger {}
interface DisposableThreeRenderer : Disposable {
val renderer: ThreeRenderer
}
abstract class Renderer : DisposableContainer() { abstract class Renderer : DisposableContainer() {
protected abstract val context: RenderContext protected abstract val context: RenderContext
protected abstract val threeRenderer: ThreeRenderer protected abstract val threeRenderer: ThreeRenderer

View File

@ -55,7 +55,7 @@ private fun xvrTextureToUint8Array(xvr: XvrTexture): Uint8Array {
val stride = 4 * xvr.width val stride = 4 * xvr.width
var i = 0 var i = 0
while (cursor.hasBytesLeft(8)) { while (cursor.bytesLeft >= 8) {
// Each block of 4 x 4 pixels is compressed to 8 bytes. // Each block of 4 x 4 pixels is compressed to 8 bytes.
val c0 = cursor.uShort().toInt() // Color 0 val c0 = cursor.uShort().toInt() // Color 0
val c1 = cursor.uShort().toInt() // Color 1 val c1 = cursor.uShort().toInt() // Color 1

View File

@ -1,7 +1,6 @@
package world.phantasmal.web.core.stores package world.phantasmal.web.core.stores
import kotlinx.browser.window import kotlinx.browser.window
import kotlinx.coroutines.CoroutineScope
import org.w3c.dom.events.KeyboardEvent import org.w3c.dom.events.KeyboardEvent
import world.phantasmal.observable.value.MutableVal import world.phantasmal.observable.value.MutableVal
import world.phantasmal.observable.value.Val import world.phantasmal.observable.value.Val
@ -20,10 +19,7 @@ interface ApplicationUrl {
fun replaceUrl(url: String) fun replaceUrl(url: String)
} }
class UiStore( class UiStore(private val applicationUrl: ApplicationUrl) : Store() {
scope: CoroutineScope,
private val applicationUrl: ApplicationUrl,
) : Store(scope) {
private val _currentTool: MutableVal<PwToolType> private val _currentTool: MutableVal<PwToolType>
private val _path = mutableVal("") private val _path = mutableVal("")
@ -82,7 +78,7 @@ class UiStore(
.toMap() .toMap()
addDisposables( addDisposables(
disposableListener(window, "keydown", ::dispatchGlobalKeydown), window.disposableListener("keydown", ::dispatchGlobalKeydown),
) )
observe(applicationUrl.url) { setDataFromUrl(it) } observe(applicationUrl.url) { setDataFromUrl(it) }

View File

@ -1,6 +1,5 @@
package world.phantasmal.web.core.widgets package world.phantasmal.web.core.widgets
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.w3c.dom.Node import org.w3c.dom.Node
import world.phantasmal.observable.value.Val import world.phantasmal.observable.value.Val
@ -12,11 +11,10 @@ import world.phantasmal.webui.obj
import world.phantasmal.webui.widgets.Widget import world.phantasmal.webui.widgets.Widget
class DockWidget( class DockWidget(
scope: CoroutineScope,
visible: Val<Boolean> = trueVal(), visible: Val<Boolean> = trueVal(),
private val ctrl: DockController, private val ctrl: DockController,
private val createWidget: (scope: CoroutineScope, id: String) -> Widget?, private val createWidget: (id: String) -> Widget?,
) : Widget(scope, visible) { ) : Widget(visible) {
private var goldenLayout: GoldenLayout? = null private var goldenLayout: GoldenLayout? = null
init { init {
@ -49,7 +47,7 @@ class DockWidget(
goldenLayout.registerComponent(id) { container: GoldenLayout.Container -> goldenLayout.registerComponent(id) { container: GoldenLayout.Container ->
val node = container.getElement()[0] as Node val node = container.getElement()[0] as Node
createWidget(scope, id)?.let { widget -> createWidget(id)?.let { widget ->
node.addChild(widget) node.addChild(widget)
widget.focus() widget.focus()
} }

View File

@ -1,15 +1,13 @@
package world.phantasmal.web.core.widgets package world.phantasmal.web.core.widgets
import kotlinx.coroutines.CoroutineScope
import org.w3c.dom.Node import org.w3c.dom.Node
import world.phantasmal.web.core.rendering.Renderer import world.phantasmal.web.core.rendering.Renderer
import world.phantasmal.webui.dom.div import world.phantasmal.webui.dom.div
import world.phantasmal.webui.widgets.Widget import world.phantasmal.webui.widgets.Widget
class RendererWidget( class RendererWidget(
scope: CoroutineScope,
private val renderer: Renderer, private val renderer: Renderer,
) : Widget(scope) { ) : Widget() {
override fun Node.createElement() = override fun Node.createElement() =
div { div {
className = "pw-core-renderer" className = "pw-core-renderer"

View File

@ -1,6 +1,5 @@
package world.phantasmal.web.core.widgets package world.phantasmal.web.core.widgets
import kotlinx.coroutines.CoroutineScope
import org.w3c.dom.Node import org.w3c.dom.Node
import world.phantasmal.observable.value.Val import world.phantasmal.observable.value.Val
import world.phantasmal.observable.value.falseVal import world.phantasmal.observable.value.falseVal
@ -9,15 +8,14 @@ import world.phantasmal.webui.widgets.Label
import world.phantasmal.webui.widgets.Widget import world.phantasmal.webui.widgets.Widget
class UnavailableWidget( class UnavailableWidget(
scope: CoroutineScope,
visible: Val<Boolean>, visible: Val<Boolean>,
private val message: String, private val message: String,
) : Widget(scope, visible) { ) : Widget(visible) {
override fun Node.createElement() = override fun Node.createElement() =
div { div {
className = "pw-core-unavailable" className = "pw-core-unavailable"
addWidget(Label(scope, enabled = falseVal(), text = message)) addWidget(Label(enabled = falseVal(), text = message))
} }
companion object { companion object {

View File

@ -1,6 +1,6 @@
@file:JsModule("three/examples/jsm/controls/OrbitControls") @file:JsModule("three/examples/jsm/controls/OrbitControls")
@file:JsNonModule @file:JsNonModule
@file:Suppress("PropertyName") @file:Suppress("PropertyName", "unused")
package world.phantasmal.web.externals.three package world.phantasmal.web.externals.three
@ -14,6 +14,9 @@ external interface OrbitControlsMouseButtons {
external class OrbitControls(`object`: Camera, domElement: HTMLElement = definedExternally) { external class OrbitControls(`object`: Camera, domElement: HTMLElement = definedExternally) {
var enabled: Boolean var enabled: Boolean
var enablePan: Boolean
var enableRotate: Boolean
var enableZoom: Boolean
var target: Vector3 var target: Vector3
var zoomSpeed: Double var zoomSpeed: Double
var screenSpacePanning: Boolean var screenSpacePanning: Boolean

View File

@ -18,6 +18,7 @@ external class Vector2(x: Double = definedExternally, y: Double = definedExterna
* Sets value of this vector. * Sets value of this vector.
*/ */
fun set(x: Double, y: Double): Vector2 fun set(x: Double, y: Double): Vector2
fun clone(): Vector2
/** /**
* Copies value of v to this vector. * 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. * Checks for strict equality of this vector and v.
*/ */
fun equals(v: Vector2): Boolean fun equals(v: Vector2): Boolean
fun distanceTo(v: Vector2): Double
} }
external class Vector3( external class Vector3(
@ -172,6 +175,16 @@ external class Plane(normal: Vector3 = definedExternally, constant: Double = def
fun projectPoint(point: Vector3, target: Vector3): Vector3 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 open external class EventDispatcher
external interface Renderer { external interface Renderer {
@ -192,15 +205,23 @@ external interface WebGLRendererParameters {
var antialias: Boolean var antialias: Boolean
} }
external class WebGLRenderer(parameters: WebGLRendererParameters = definedExternally) : Renderer { open external class WebGLRenderer(
parameters: WebGLRendererParameters = definedExternally,
) : Renderer {
override val domElement: HTMLCanvasElement override val domElement: HTMLCanvasElement
var autoClearColor: Boolean
override fun render(scene: Object3D, camera: Camera) override fun render(scene: Object3D, camera: Camera)
override fun setSize(width: Double, height: Double) override fun setSize(width: Double, height: Double)
fun setPixelRatio(value: Double) fun setPixelRatio(value: Double)
fun setClearColor(color: Color, alpha: Double = definedExternally)
fun clearColor()
fun dispose() fun dispose()
} }
@ -252,6 +273,9 @@ open external class Object3D {
fun remove(vararg `object`: Object3D): Object3D fun remove(vararg `object`: Object3D): Object3D
fun clear(): Object3D fun clear(): Object3D
fun lookAt(vector: Vector3)
fun lookAt(x: Double, y: Double, z: Double)
/** /**
* Updates local transform. * Updates local transform.
*/ */
@ -479,6 +503,7 @@ external class PlaneGeometry(
open external class BufferGeometry : EventDispatcher { open external class BufferGeometry : EventDispatcher {
var boundingBox: Box3? var boundingBox: Box3?
var boundingSphere: Sphere?
fun setIndex(index: BufferAttribute?) fun setIndex(index: BufferAttribute?)
fun setIndex(index: Array<Double>?) fun setIndex(index: Array<Double>?)
@ -656,11 +681,6 @@ external class CompressedTexture(
encoding: TextureEncoding = definedExternally, encoding: TextureEncoding = definedExternally,
) : Texture ) : Texture
external class Box3(min: Vector3 = definedExternally, max: Vector3 = definedExternally) {
var min: Vector3
var max: Vector3
}
external enum class MOUSE { external enum class MOUSE {
LEFT, LEFT,
MIDDLE, MIDDLE,

View File

@ -1,6 +1,5 @@
package world.phantasmal.web.huntOptimizer package world.phantasmal.web.huntOptimizer
import kotlinx.coroutines.CoroutineScope
import world.phantasmal.web.core.PwTool import world.phantasmal.web.core.PwTool
import world.phantasmal.web.core.PwToolType import world.phantasmal.web.core.PwToolType
import world.phantasmal.web.core.loading.AssetLoader import world.phantasmal.web.core.loading.AssetLoader
@ -19,9 +18,9 @@ class HuntOptimizer(
) : DisposableContainer(), PwTool { ) : DisposableContainer(), PwTool {
override val toolType = PwToolType.HuntOptimizer override val toolType = PwToolType.HuntOptimizer
override fun initialize(scope: CoroutineScope): Widget { override fun initialize(): Widget {
// Stores // Stores
val huntMethodStore = addDisposable(HuntMethodStore(scope, uiStore, assetLoader)) val huntMethodStore = addDisposable(HuntMethodStore(uiStore, assetLoader))
// Controllers // Controllers
val huntOptimizerController = addDisposable(HuntOptimizerController(uiStore)) val huntOptimizerController = addDisposable(HuntOptimizerController(uiStore))
@ -29,9 +28,8 @@ class HuntOptimizer(
// Main Widget // Main Widget
return HuntOptimizerWidget( return HuntOptimizerWidget(
scope,
ctrl = huntOptimizerController, ctrl = huntOptimizerController,
createMethodsWidget = { s -> MethodsWidget(s, methodsController) } createMethodsWidget = { MethodsWidget(methodsController) }
) )
} }
} }

View File

@ -1,6 +1,5 @@
package world.phantasmal.web.huntOptimizer.stores package world.phantasmal.web.huntOptimizer.stores
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import world.phantasmal.lib.fileFormats.quest.Episode import world.phantasmal.lib.fileFormats.quest.Episode
@ -22,10 +21,9 @@ import kotlin.collections.set
import kotlin.time.minutes import kotlin.time.minutes
class HuntMethodStore( class HuntMethodStore(
scope: CoroutineScope,
uiStore: UiStore, uiStore: UiStore,
private val assetLoader: AssetLoader, private val assetLoader: AssetLoader,
) : Store(scope) { ) : Store() {
private val _methods = mutableListVal<HuntMethodModel>() private val _methods = mutableListVal<HuntMethodModel>()
val methods: ListVal<HuntMethodModel> by lazy { val methods: ListVal<HuntMethodModel> by lazy {
@ -34,7 +32,7 @@ class HuntMethodStore(
} }
private fun loadMethods(server: Server) { private fun loadMethods(server: Server) {
launch(IoDispatcher) { scope.launch(IoDispatcher) {
val quests = assetLoader.load<List<QuestDto>>("/quests.${server.slug}.json") val quests = assetLoader.load<List<QuestDto>>("/quests.${server.slug}.json")
val methods = quests val methods = quests

View File

@ -1,12 +1,11 @@
package world.phantasmal.web.huntOptimizer.widgets package world.phantasmal.web.huntOptimizer.widgets
import kotlinx.coroutines.CoroutineScope
import org.w3c.dom.Node import org.w3c.dom.Node
import world.phantasmal.webui.dom.div import world.phantasmal.webui.dom.div
import world.phantasmal.webui.dom.p import world.phantasmal.webui.dom.p
import world.phantasmal.webui.widgets.Widget import world.phantasmal.webui.widgets.Widget
class HelpWidget(scope: CoroutineScope) : Widget(scope) { class HelpWidget() : Widget() {
override fun Node.createElement() = override fun Node.createElement() =
div { div {
className = "pw-hunt-optimizer-help" className = "pw-hunt-optimizer-help"

View File

@ -1,6 +1,5 @@
package world.phantasmal.web.huntOptimizer.widgets package world.phantasmal.web.huntOptimizer.widgets
import kotlinx.coroutines.CoroutineScope
import org.w3c.dom.Node import org.w3c.dom.Node
import world.phantasmal.web.huntOptimizer.HuntOptimizerUrls import world.phantasmal.web.huntOptimizer.HuntOptimizerUrls
import world.phantasmal.web.huntOptimizer.controllers.HuntOptimizerController import world.phantasmal.web.huntOptimizer.controllers.HuntOptimizerController
@ -9,26 +8,24 @@ import world.phantasmal.webui.widgets.TabContainer
import world.phantasmal.webui.widgets.Widget import world.phantasmal.webui.widgets.Widget
class HuntOptimizerWidget( class HuntOptimizerWidget(
scope: CoroutineScope,
private val ctrl: HuntOptimizerController, private val ctrl: HuntOptimizerController,
private val createMethodsWidget: (CoroutineScope) -> MethodsWidget, private val createMethodsWidget: () -> MethodsWidget,
) : Widget(scope) { ) : Widget() {
override fun Node.createElement() = override fun Node.createElement() =
div { div {
className = "pw-hunt-optimizer-hunt-optimizer" className = "pw-hunt-optimizer-hunt-optimizer"
addChild(TabContainer( addChild(TabContainer(
scope,
ctrl = ctrl, ctrl = ctrl,
createWidget = { scope, tab -> createWidget = { tab ->
when (tab.path) { when (tab.path) {
HuntOptimizerUrls.optimize -> object : Widget(scope) { HuntOptimizerUrls.optimize -> object : Widget() {
override fun Node.createElement() = div { override fun Node.createElement() = div {
textContent = "TODO" textContent = "TODO"
} }
} }
HuntOptimizerUrls.methods -> createMethodsWidget(scope) HuntOptimizerUrls.methods -> createMethodsWidget()
HuntOptimizerUrls.help -> HelpWidget(scope) HuntOptimizerUrls.help -> HelpWidget()
else -> error("""Unknown tab "${tab.title}".""") else -> error("""Unknown tab "${tab.title}".""")
} }
} }

View File

@ -1,6 +1,5 @@
package world.phantasmal.web.huntOptimizer.widgets package world.phantasmal.web.huntOptimizer.widgets
import kotlinx.coroutines.CoroutineScope
import org.w3c.dom.Node import org.w3c.dom.Node
import world.phantasmal.lib.fileFormats.quest.Episode import world.phantasmal.lib.fileFormats.quest.Episode
import world.phantasmal.web.huntOptimizer.controllers.MethodsController import world.phantasmal.web.huntOptimizer.controllers.MethodsController
@ -8,10 +7,9 @@ import world.phantasmal.webui.dom.div
import world.phantasmal.webui.widgets.Widget import world.phantasmal.webui.widgets.Widget
class MethodsForEpisodeWidget( class MethodsForEpisodeWidget(
scope: CoroutineScope,
private val ctrl: MethodsController, private val ctrl: MethodsController,
private val episode: Episode, private val episode: Episode,
) : Widget(scope) { ) : Widget() {
override fun Node.createElement() = override fun Node.createElement() =
div { div {
className = "pw-hunt-optimizer-methods-for-episode" className = "pw-hunt-optimizer-methods-for-episode"

View File

@ -1,6 +1,5 @@
package world.phantasmal.web.huntOptimizer.widgets package world.phantasmal.web.huntOptimizer.widgets
import kotlinx.coroutines.CoroutineScope
import org.w3c.dom.Node import org.w3c.dom.Node
import world.phantasmal.web.huntOptimizer.controllers.MethodsController import world.phantasmal.web.huntOptimizer.controllers.MethodsController
import world.phantasmal.webui.dom.div import world.phantasmal.webui.dom.div
@ -8,15 +7,14 @@ import world.phantasmal.webui.widgets.TabContainer
import world.phantasmal.webui.widgets.Widget import world.phantasmal.webui.widgets.Widget
class MethodsWidget( class MethodsWidget(
scope: CoroutineScope,
private val ctrl: MethodsController, private val ctrl: MethodsController,
) : Widget(scope) { ) : Widget() {
override fun Node.createElement() = override fun Node.createElement() =
div { div {
className = "pw-hunt-optimizer-methods" className = "pw-hunt-optimizer-methods"
addChild(TabContainer(scope, ctrl = ctrl, createWidget = { scope, tab -> addChild(TabContainer(ctrl = ctrl, createWidget = { tab ->
MethodsForEpisodeWidget(scope, ctrl, tab.episode) MethodsForEpisodeWidget(ctrl, tab.episode)
})) }))
} }

View File

@ -1,6 +1,5 @@
package world.phantasmal.web.questEditor package world.phantasmal.web.questEditor
import kotlinx.coroutines.CoroutineScope
import org.w3c.dom.HTMLCanvasElement import org.w3c.dom.HTMLCanvasElement
import world.phantasmal.web.core.PwTool import world.phantasmal.web.core.PwTool
import world.phantasmal.web.core.PwToolType 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.EntityAssetLoader
import world.phantasmal.web.questEditor.loading.QuestLoader import world.phantasmal.web.questEditor.loading.QuestLoader
import world.phantasmal.web.questEditor.persistence.QuestEditorUiPersister 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.rendering.QuestRenderer
import world.phantasmal.web.questEditor.stores.AreaStore import world.phantasmal.web.questEditor.stores.AreaStore
import world.phantasmal.web.questEditor.stores.AssemblyEditorStore import world.phantasmal.web.questEditor.stores.AssemblyEditorStore
@ -27,19 +27,19 @@ class QuestEditor(
) : DisposableContainer(), PwTool { ) : DisposableContainer(), PwTool {
override val toolType = PwToolType.QuestEditor override val toolType = PwToolType.QuestEditor
override fun initialize(scope: CoroutineScope): Widget { override fun initialize(): Widget {
// Asset Loaders // Asset Loaders
val questLoader = addDisposable(QuestLoader(scope, assetLoader)) val questLoader = addDisposable(QuestLoader(assetLoader))
val areaAssetLoader = addDisposable(AreaAssetLoader(scope, assetLoader)) val areaAssetLoader = addDisposable(AreaAssetLoader(assetLoader))
val entityAssetLoader = addDisposable(EntityAssetLoader(scope, assetLoader)) val entityAssetLoader = addDisposable(EntityAssetLoader(assetLoader))
// Persistence // Persistence
val questEditorUiPersister = QuestEditorUiPersister() val questEditorUiPersister = QuestEditorUiPersister()
// Stores // Stores
val areaStore = addDisposable(AreaStore(scope, areaAssetLoader)) val areaStore = addDisposable(AreaStore(areaAssetLoader))
val questEditorStore = addDisposable(QuestEditorStore(scope, uiStore, areaStore)) val questEditorStore = addDisposable(QuestEditorStore(uiStore, areaStore))
val assemblyEditorStore = addDisposable(AssemblyEditorStore(scope, questEditorStore)) val assemblyEditorStore = addDisposable(AssemblyEditorStore(questEditorStore))
// Controllers // Controllers
val questEditorController = addDisposable(QuestEditorController(questEditorUiPersister)) val questEditorController = addDisposable(QuestEditorController(questEditorUiPersister))
@ -58,25 +58,24 @@ class QuestEditor(
// Rendering // Rendering
val renderer = addDisposable(QuestRenderer( val renderer = addDisposable(QuestRenderer(
scope,
areaAssetLoader, areaAssetLoader,
entityAssetLoader, entityAssetLoader,
questEditorStore, questEditorStore,
createThreeRenderer, createThreeRenderer,
)) ))
val entityImageRenderer = EntityImageRenderer(entityAssetLoader, createThreeRenderer)
// Main Widget // Main Widget
return QuestEditorWidget( return QuestEditorWidget(
scope,
questEditorController, questEditorController,
{ s -> QuestEditorToolbarWidget(s, toolbarController) }, { QuestEditorToolbarWidget(toolbarController) },
{ s -> QuestInfoWidget(s, questInfoController) }, { QuestInfoWidget(questInfoController) },
{ s -> NpcCountsWidget(s, npcCountsController) }, { NpcCountsWidget(npcCountsController) },
{ s -> EntityInfoWidget(s, entityInfoController) }, { EntityInfoWidget(entityInfoController) },
{ s -> QuestEditorRendererWidget(s, renderer) }, { QuestEditorRendererWidget(renderer) },
{ s -> AssemblyEditorWidget(s, assemblyEditorController) }, { AssemblyEditorWidget(assemblyEditorController) },
{ s -> EntityListWidget(s, npcListController) }, { EntityListWidget(npcListController, entityImageRenderer) },
{ s -> EntityListWidget(s, objectListController) }, { EntityListWidget(objectListController, entityImageRenderer) },
) )
} }
} }

View File

@ -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)
}
}

View File

@ -1,6 +1,5 @@
package world.phantasmal.web.questEditor.loading package world.phantasmal.web.questEditor.loading
import kotlinx.coroutines.CoroutineScope
import org.khronos.webgl.ArrayBuffer import org.khronos.webgl.ArrayBuffer
import world.phantasmal.lib.Endianness import world.phantasmal.lib.Endianness
import world.phantasmal.lib.cursor.cursor import world.phantasmal.lib.cursor.cursor
@ -24,17 +23,13 @@ import world.phantasmal.webui.obj
/** /**
* Loads and caches area assets. * Loads and caches area assets.
*/ */
class AreaAssetLoader( class AreaAssetLoader(private val assetLoader: AssetLoader) : DisposableContainer() {
scope: CoroutineScope,
private val assetLoader: AssetLoader,
) : DisposableContainer() {
/** /**
* This cache's values consist of an Object3D containing the area render meshes and a list of * This cache's values consist of an Object3D containing the area render meshes and a list of
* that area's sections. * that area's sections.
*/ */
private val renderObjectCache = addDisposable( private val renderObjectCache = addDisposable(
LoadingCache<EpisodeAndAreaVariant, Pair<Object3D, List<SectionModel>>>( LoadingCache<EpisodeAndAreaVariant, Pair<Object3D, List<SectionModel>>>(
scope,
{ (episode, areaVariant) -> { (episode, areaVariant) ->
val buffer = getAreaAsset(episode, areaVariant, AssetType.Render) val buffer = getAreaAsset(episode, areaVariant, AssetType.Render)
val obj = parseAreaGeometry(buffer.cursor(Endianness.Little)) val obj = parseAreaGeometry(buffer.cursor(Endianness.Little))
@ -46,7 +41,6 @@ class AreaAssetLoader(
private val collisionObjectCache = addDisposable( private val collisionObjectCache = addDisposable(
LoadingCache<EpisodeAndAreaVariant, Object3D>( LoadingCache<EpisodeAndAreaVariant, Object3D>(
scope,
{ (episode, areaVariant) -> { (episode, areaVariant) ->
val buffer = getAreaAsset(episode, areaVariant, AssetType.Collision) val buffer = getAreaAsset(episode, areaVariant, AssetType.Collision)
val obj = parseAreaCollisionGeometry(buffer.cursor(Endianness.Little)) val obj = parseAreaCollisionGeometry(buffer.cursor(Endianness.Little))

View File

@ -1,6 +1,5 @@
package world.phantasmal.web.questEditor.loading package world.phantasmal.web.questEditor.loading
import kotlinx.coroutines.CoroutineScope
import mu.KotlinLogging import mu.KotlinLogging
import org.khronos.webgl.ArrayBuffer import org.khronos.webgl.ArrayBuffer
import world.phantasmal.core.PwResult import world.phantasmal.core.PwResult
@ -22,13 +21,9 @@ import world.phantasmal.webui.DisposableContainer
private val logger = KotlinLogging.logger {} private val logger = KotlinLogging.logger {}
class EntityAssetLoader( class EntityAssetLoader(private val assetLoader: AssetLoader) : DisposableContainer() {
scope: CoroutineScope,
private val assetLoader: AssetLoader,
) : DisposableContainer() {
private val instancedMeshCache = addDisposable( private val instancedMeshCache = addDisposable(
LoadingCache<Pair<EntityType, Int?>, InstancedMesh>( LoadingCache<Pair<EntityType, Int?>, InstancedMesh>(
scope,
{ (type, model) -> { (type, model) ->
try { try {
loadMesh(type, model) ?: DEFAULT_MESH loadMesh(type, model) ?: DEFAULT_MESH
@ -139,7 +134,10 @@ class EntityAssetLoader(
}, },
MeshLambertMaterial(), MeshLambertMaterial(),
count = 1000, count = 1000,
) ).apply {
// Start with 0 instances.
count = 0
}
} }
} }

View File

@ -1,17 +1,14 @@
package world.phantasmal.web.questEditor.loading package world.phantasmal.web.questEditor.loading
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.*
import kotlinx.coroutines.Deferred
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.async
import world.phantasmal.core.disposable.TrackedDisposable import world.phantasmal.core.disposable.TrackedDisposable
@OptIn(ExperimentalCoroutinesApi::class) @OptIn(ExperimentalCoroutinesApi::class)
class LoadingCache<K, V>( class LoadingCache<K, V>(
private val scope: CoroutineScope,
private val loadValue: suspend (K) -> V, private val loadValue: suspend (K) -> V,
private val disposeValue: (V) -> Unit, private val disposeValue: (V) -> Unit,
) : TrackedDisposable() { ) : TrackedDisposable() {
private val scope: CoroutineScope = CoroutineScope(Dispatchers.Default)
private val map = mutableMapOf<K, Deferred<V>>() private val map = mutableMapOf<K, Deferred<V>>()
val values: Collection<Deferred<V>> = map.values val values: Collection<Deferred<V>> = map.values
@ -31,6 +28,7 @@ class LoadingCache<K, V>(
} }
} }
scope.cancel("LoadingCache disposed.")
super.internalDispose() super.internalDispose()
} }
} }

View File

@ -1,6 +1,5 @@
package world.phantasmal.web.questEditor.loading package world.phantasmal.web.questEditor.loading
import kotlinx.coroutines.CoroutineScope
import org.khronos.webgl.ArrayBuffer import org.khronos.webgl.ArrayBuffer
import world.phantasmal.lib.Endianness import world.phantasmal.lib.Endianness
import world.phantasmal.lib.cursor.cursor 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.web.core.loading.AssetLoader
import world.phantasmal.webui.DisposableContainer import world.phantasmal.webui.DisposableContainer
class QuestLoader( class QuestLoader(private val assetLoader: AssetLoader) : DisposableContainer() {
scope: CoroutineScope,
private val assetLoader: AssetLoader,
) : DisposableContainer() {
private val cache = addDisposable( private val cache = addDisposable(
LoadingCache<String, ArrayBuffer>( LoadingCache<String, ArrayBuffer>(
scope,
{ path -> assetLoader.loadArrayBuffer("/quests$path") }, { path -> assetLoader.loadArrayBuffer("/quests$path") },
{ /* Nothing to dispose. */ } { /* Nothing to dispose. */ }
) )

View File

@ -132,4 +132,26 @@ class QuestModel(
_longDescription.value = longDescription _longDescription.value = longDescription
return this 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)
}
}
} }

View File

@ -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<EntityType, String> = 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
})
}
}

View File

@ -39,7 +39,7 @@ class EntityInstancedMesh(
entity, entity,
mesh, mesh,
instanceIndex, instanceIndex,
selectedWave selectedWave,
) { index -> ) { index ->
removeAt(index) removeAt(index)
modelChanged(entity) modelChanged(entity)

View File

@ -15,17 +15,17 @@ import world.phantasmal.webui.DisposableContainer
private val logger = KotlinLogging.logger {} private val logger = KotlinLogging.logger {}
class EntityMeshManager( class EntityMeshManager(
private val scope: CoroutineScope,
private val questEditorStore: QuestEditorStore, private val questEditorStore: QuestEditorStore,
private val renderContext: QuestRenderContext, private val renderContext: QuestRenderContext,
private val entityAssetLoader: EntityAssetLoader, private val entityAssetLoader: EntityAssetLoader,
) : DisposableContainer() { ) : DisposableContainer() {
private val scope: CoroutineScope = CoroutineScope(Dispatchers.Main)
/** /**
* Contains one [EntityInstancedMesh] per [EntityType] and model. * Contains one [EntityInstancedMesh] per [EntityType] and model.
*/ */
private val entityMeshCache = addDisposable( private val entityMeshCache = addDisposable(
LoadingCache<TypeAndModel, EntityInstancedMesh>( LoadingCache<TypeAndModel, EntityInstancedMesh>(
scope,
{ (type, model) -> { (type, model) ->
val mesh = entityAssetLoader.loadInstancedMesh(type, model) val mesh = entityAssetLoader.loadInstancedMesh(type, model)
renderContext.entities.add(mesh) renderContext.entities.add(mesh)
@ -87,13 +87,12 @@ class EntityMeshManager(
loadingEntities.getOrPut(entity) { loadingEntities.getOrPut(entity) {
scope.launch { scope.launch {
try { try {
val meshContainer = entityMeshCache.get(TypeAndModel( val entityInstancedMesh = entityMeshCache.get(TypeAndModel(
type = entity.type, type = entity.type,
model = (entity as? QuestObjectModel)?.model?.value model = (entity as? QuestObjectModel)?.model?.value
)) ))
val instance = meshContainer.addInstance(entity) val instance = entityInstancedMesh.addInstance(entity)
loadingEntities.remove(entity)
if (entity == questEditorStore.selectedEntity.value) { if (entity == questEditorStore.selectedEntity.value) {
markSelected(instance) markSelected(instance)
@ -103,10 +102,11 @@ class EntityMeshManager(
} catch (e: CancellationException) { } catch (e: CancellationException) {
// Do nothing. // Do nothing.
} catch (e: Throwable) { } catch (e: Throwable) {
loadingEntities.remove(entity)
logger.error(e) { logger.error(e) {
"Couldn't load mesh for entity of type ${entity.type}." "Couldn't load mesh for entity of type ${entity.type}."
} }
} finally {
loadingEntities.remove(entity)
} }
} }
} }

View File

@ -1,6 +1,5 @@
package world.phantasmal.web.questEditor.rendering package world.phantasmal.web.questEditor.rendering
import kotlinx.coroutines.CoroutineScope
import world.phantasmal.lib.fileFormats.quest.Episode import world.phantasmal.lib.fileFormats.quest.Episode
import world.phantasmal.observable.value.list.ListVal import world.phantasmal.observable.value.list.ListVal
import world.phantasmal.observable.value.list.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 import world.phantasmal.web.questEditor.stores.QuestEditorStore
class QuestEditorMeshManager( class QuestEditorMeshManager(
scope: CoroutineScope,
areaAssetLoader: AreaAssetLoader, areaAssetLoader: AreaAssetLoader,
entityAssetLoader: EntityAssetLoader, entityAssetLoader: EntityAssetLoader,
questEditorStore: QuestEditorStore, questEditorStore: QuestEditorStore,
renderContext: QuestRenderContext, renderContext: QuestRenderContext,
) : QuestMeshManager(scope, areaAssetLoader, entityAssetLoader, questEditorStore, renderContext) { ) : QuestMeshManager(areaAssetLoader, entityAssetLoader, questEditorStore, renderContext) {
init { init {
addDisposables( addDisposables(
map( map(

View File

@ -1,6 +1,7 @@
package world.phantasmal.web.questEditor.rendering package world.phantasmal.web.questEditor.rendering
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import world.phantasmal.core.disposable.Disposer 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]. * Loads the necessary area and entity 3D models into [QuestRenderer].
*/ */
abstract class QuestMeshManager protected constructor( abstract class QuestMeshManager protected constructor(
private val scope: CoroutineScope,
areaAssetLoader: AreaAssetLoader, areaAssetLoader: AreaAssetLoader,
entityAssetLoader: EntityAssetLoader, entityAssetLoader: EntityAssetLoader,
questEditorStore: QuestEditorStore, questEditorStore: QuestEditorStore,
renderContext: QuestRenderContext, renderContext: QuestRenderContext,
) : DisposableContainer() { ) : DisposableContainer() {
private val scope: CoroutineScope = CoroutineScope(Dispatchers.Default)
private val areaDisposer = addDisposable(Disposer()) private val areaDisposer = addDisposable(Disposer())
private val areaMeshManager = AreaMeshManager(renderContext, areaAssetLoader) private val areaMeshManager = AreaMeshManager(renderContext, areaAssetLoader)
private val entityMeshManager = addDisposable( private val entityMeshManager = addDisposable(
EntityMeshManager(scope, questEditorStore, renderContext, entityAssetLoader) EntityMeshManager(questEditorStore, renderContext, entityAssetLoader)
) )
private var loadJob: Job? = null private var loadJob: Job? = null

View File

@ -11,7 +11,6 @@ import world.phantasmal.web.questEditor.rendering.input.QuestInputManager
import world.phantasmal.web.questEditor.stores.QuestEditorStore import world.phantasmal.web.questEditor.stores.QuestEditorStore
class QuestRenderer( class QuestRenderer(
scope: CoroutineScope,
areaAssetLoader: AreaAssetLoader, areaAssetLoader: AreaAssetLoader,
entityAssetLoader: EntityAssetLoader, entityAssetLoader: EntityAssetLoader,
questEditorStore: QuestEditorStore, questEditorStore: QuestEditorStore,
@ -34,7 +33,6 @@ class QuestRenderer(
init { init {
addDisposables( addDisposables(
QuestEditorMeshManager( QuestEditorMeshManager(
scope,
areaAssetLoader, areaAssetLoader,
entityAssetLoader, entityAssetLoader,
questEditorStore, questEditorStore,

View File

@ -1,37 +1,82 @@
package world.phantasmal.web.questEditor.rendering.input 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.externals.three.Vector2
import world.phantasmal.web.questEditor.widgets.EntityDragEvent
sealed class Evt sealed class Evt
sealed class PointerEvt : Evt() { sealed class PointerEvt : Evt() {
abstract val buttons: Int abstract val buttons: Int
abstract val shiftKeyDown: Boolean abstract val shiftKeyDown: Boolean
abstract val movedSinceLastPointerDown: Boolean
/** /**
* Pointer position in normalized device space. * Pointer position in normalized device space.
*/ */
abstract val pointerDevicePosition: Vector2 abstract val pointerDevicePosition: Vector2
abstract val movedSinceLastPointerDown: Boolean
} }
class PointerDownEvt( class PointerDownEvt(
override val buttons: Int, override val buttons: Int,
override val shiftKeyDown: Boolean, override val shiftKeyDown: Boolean,
override val movedSinceLastPointerDown: Boolean,
override val pointerDevicePosition: Vector2, override val pointerDevicePosition: Vector2,
override val movedSinceLastPointerDown: Boolean,
) : PointerEvt() ) : PointerEvt()
class PointerUpEvt( class PointerUpEvt(
override val buttons: Int, override val buttons: Int,
override val shiftKeyDown: Boolean, override val shiftKeyDown: Boolean,
override val movedSinceLastPointerDown: Boolean,
override val pointerDevicePosition: Vector2, override val pointerDevicePosition: Vector2,
override val movedSinceLastPointerDown: Boolean,
) : PointerEvt() ) : PointerEvt()
class PointerMoveEvt( class PointerMoveEvt(
override val buttons: Int, override val buttons: Int,
override val shiftKeyDown: Boolean, override val shiftKeyDown: Boolean,
override val movedSinceLastPointerDown: Boolean,
override val pointerDevicePosition: Vector2, override val pointerDevicePosition: Vector2,
override val movedSinceLastPointerDown: Boolean,
) : PointerEvt() ) : 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)

View File

@ -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.State
import world.phantasmal.web.questEditor.rendering.input.state.StateContext import world.phantasmal.web.questEditor.rendering.input.state.StateContext
import world.phantasmal.web.questEditor.stores.QuestEditorStore import world.phantasmal.web.questEditor.stores.QuestEditorStore
import world.phantasmal.web.questEditor.widgets.*
import world.phantasmal.webui.DisposableContainer import world.phantasmal.webui.DisposableContainer
import world.phantasmal.webui.dom.disposableListener import world.phantasmal.webui.dom.disposableListener
@ -42,11 +43,18 @@ class QuestInputManager(
init { init {
addDisposables( addDisposables(
disposableListener(renderContext.canvas, "pointerdown", ::onPointerDown) renderContext.canvas.disposableListener("pointerdown", ::onPointerDown)
) )
onPointerMoveListener = 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. // Ensure OrbitalCameraControls attaches its listeners after ours.
cameraInputManager = OrbitalCameraInputManager( cameraInputManager = OrbitalCameraInputManager(
@ -90,16 +98,16 @@ class QuestInputManager(
PointerDownEvt( PointerDownEvt(
e.buttons.toInt(), e.buttons.toInt(),
shiftKeyDown = e.shiftKey, shiftKeyDown = e.shiftKey,
movedSinceLastPointerDown,
pointerDevicePosition, 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. // Stop listening to canvas move events and start listening to window move events.
onPointerMoveListener?.dispose() onPointerMoveListener?.dispose()
onPointerMoveListener = disposableListener(window, "pointermove", ::onPointerMove) onPointerMoveListener = window.disposableListener("pointermove", ::onPointerMove)
} }
private fun onPointerUp(e: PointerEvent) { private fun onPointerUp(e: PointerEvent) {
@ -110,8 +118,8 @@ class QuestInputManager(
PointerUpEvt( PointerUpEvt(
e.buttons.toInt(), e.buttons.toInt(),
shiftKeyDown = e.shiftKey, shiftKeyDown = e.shiftKey,
movedSinceLastPointerDown,
pointerDevicePosition, pointerDevicePosition,
movedSinceLastPointerDown,
) )
) )
} finally { } finally {
@ -121,7 +129,7 @@ class QuestInputManager(
// Stop listening to window move events and start listening to canvas move events again. // Stop listening to window move events and start listening to canvas move events again.
onPointerMoveListener?.dispose() onPointerMoveListener?.dispose()
onPointerMoveListener = onPointerMoveListener =
disposableListener(renderContext.canvas, "pointermove", ::onPointerMove) renderContext.canvas.disposableListener("pointermove", ::onPointerMove)
} }
} }
@ -132,19 +140,47 @@ class QuestInputManager(
PointerMoveEvt( PointerMoveEvt(
e.buttons.toInt(), e.buttons.toInt(),
shiftKeyDown = e.shiftKey, shiftKeyDown = e.shiftKey,
movedSinceLastPointerDown,
pointerDevicePosition, 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) { 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() 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) pointerDevicePosition.copy(pointerPosition)
renderContext.pointerPosToDeviceCoords(pointerDevicePosition) renderContext.pointerPosToDeviceCoords(pointerDevicePosition)
when (e.type) { when (type) {
"pointerdown" -> { "pointerdown" -> {
movedSinceLastPointerDown = false movedSinceLastPointerDown = false
} }

View File

@ -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)
}
}

View File

@ -5,10 +5,7 @@ import world.phantasmal.web.externals.three.Vector2
import world.phantasmal.web.externals.three.Vector3 import world.phantasmal.web.externals.three.Vector3
import world.phantasmal.web.questEditor.models.QuestEntityModel import world.phantasmal.web.questEditor.models.QuestEntityModel
import world.phantasmal.web.questEditor.rendering.EntityInstancedMesh import world.phantasmal.web.questEditor.rendering.EntityInstancedMesh
import world.phantasmal.web.questEditor.rendering.input.Evt import world.phantasmal.web.questEditor.rendering.input.*
import world.phantasmal.web.questEditor.rendering.input.PointerDownEvt
import world.phantasmal.web.questEditor.rendering.input.PointerMoveEvt
import world.phantasmal.web.questEditor.rendering.input.PointerUpEvt
class IdleState( class IdleState(
private val ctx: StateContext, private val ctx: StateContext,
@ -87,6 +84,17 @@ class IdleState(
shouldCheckHighlight = true 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 return this

View File

@ -1,14 +1,14 @@
package world.phantasmal.web.questEditor.rendering.input.state 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.minusAssign
import world.phantasmal.web.core.plusAssign import world.phantasmal.web.core.plusAssign
import world.phantasmal.web.core.rendering.OrbitalCameraInputManager import world.phantasmal.web.core.rendering.OrbitalCameraInputManager
import world.phantasmal.web.core.toQuaternion
import world.phantasmal.web.externals.three.* 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.RotateEntityAction
import world.phantasmal.web.questEditor.actions.TranslateEntityAction import world.phantasmal.web.questEditor.actions.TranslateEntityAction
import world.phantasmal.web.questEditor.models.QuestEntityModel import world.phantasmal.web.questEditor.models.*
import world.phantasmal.web.questEditor.models.SectionModel
import world.phantasmal.web.questEditor.rendering.QuestRenderContext import world.phantasmal.web.questEditor.rendering.QuestRenderContext
import world.phantasmal.web.questEditor.stores.QuestEditorStore import world.phantasmal.web.questEditor.stores.QuestEditorStore
import kotlin.math.PI import kotlin.math.PI
@ -19,6 +19,10 @@ class StateContext(
val renderContext: QuestRenderContext, val renderContext: QuestRenderContext,
val cameraInputManager: OrbitalCameraInputManager, val cameraInputManager: OrbitalCameraInputManager,
) { ) {
val quest: Val<QuestModel?> = questEditorStore.currentQuest
val area: Val<AreaModel?> = questEditorStore.currentArea
val wave: Val<WaveModel?> = questEditorStore.selectedWave
fun setHighlightedEntity(entity: QuestEntityModel<*, *>?) { fun setHighlightedEntity(entity: QuestEntityModel<*, *>?) {
questEditorStore.setHighlightedEntity(entity) questEditorStore.setHighlightedEntity(entity)
} }
@ -27,28 +31,11 @@ class StateContext(
questEditorStore.setSelectedEntity(entity) 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 * 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. * ground. Otherwise translate the entity over the horizontal plane that intersects its origin.
*/ */
private fun translateEntityHorizontally( fun translateEntityHorizontally(
entity: QuestEntityModel<*, *>, entity: QuestEntityModel<*, *>,
dragAdjust: Vector3, dragAdjust: Vector3,
grabOffset: Vector3, grabOffset: Vector3,
@ -80,7 +67,7 @@ class StateContext(
} }
} }
private fun translateEntityVertically( fun translateEntityVertically(
entity: QuestEntityModel<*, *>, entity: QuestEntityModel<*, *>,
dragAdjust: Vector3, dragAdjust: Vector3,
grabOffset: 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. * @param origin position in normalized device space.
*/ */

View File

@ -57,13 +57,22 @@ class TranslationState(
override fun beforeRender() { override fun beforeRender() {
if (shouldTranslate) { if (shouldTranslate) {
ctx.translateEntity( if (shouldTranslateVertically) {
entity, ctx.translateEntityVertically(
dragAdjust, entity,
grabOffset, dragAdjust,
pointerDevicePosition, grabOffset,
shouldTranslateVertically, pointerDevicePosition,
) )
} else {
ctx.translateEntityHorizontally(
entity,
dragAdjust,
grabOffset,
pointerDevicePosition,
)
}
shouldTranslate = false shouldTranslate = false
} }
} }

View File

@ -1,6 +1,5 @@
package world.phantasmal.web.questEditor.stores package world.phantasmal.web.questEditor.stores
import kotlinx.coroutines.CoroutineScope
import world.phantasmal.lib.fileFormats.quest.Episode import world.phantasmal.lib.fileFormats.quest.Episode
import world.phantasmal.web.questEditor.loading.AreaAssetLoader import world.phantasmal.web.questEditor.loading.AreaAssetLoader
import world.phantasmal.web.questEditor.models.AreaModel 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.webui.stores.Store
import world.phantasmal.lib.fileFormats.quest.getAreasForEpisode as getAreasForEpisodeLib import world.phantasmal.lib.fileFormats.quest.getAreasForEpisode as getAreasForEpisodeLib
class AreaStore( class AreaStore(private val areaAssetLoader: AreaAssetLoader) : Store() {
scope: CoroutineScope,
private val areaAssetLoader: AreaAssetLoader,
) : Store(scope) {
private val areas: Map<Episode, List<AreaModel>> private val areas: Map<Episode, List<AreaModel>>
init { init {

View File

@ -1,30 +1,27 @@
package world.phantasmal.web.questEditor.stores package world.phantasmal.web.questEditor.stores
import kotlinx.coroutines.CoroutineScope
import world.phantasmal.lib.assembly.disassemble import world.phantasmal.lib.assembly.disassemble
import world.phantasmal.observable.value.Val import world.phantasmal.observable.value.Val
import world.phantasmal.observable.value.map
import world.phantasmal.observable.value.trueVal import world.phantasmal.observable.value.trueVal
import world.phantasmal.web.externals.monacoEditor.* import world.phantasmal.web.externals.monacoEditor.*
import world.phantasmal.webui.obj import world.phantasmal.webui.obj
import world.phantasmal.webui.stores.Store import world.phantasmal.webui.stores.Store
import kotlin.js.RegExp import kotlin.js.RegExp
class AssemblyEditorStore( class AssemblyEditorStore(questEditorStore: QuestEditorStore) : Store() {
scope: CoroutineScope,
questEditorStore: QuestEditorStore,
) : Store(scope) {
private var _textModel: ITextModel? = null private var _textModel: ITextModel? = null
val inlineStackArgs: Val<Boolean> = trueVal() val inlineStackArgs: Val<Boolean> = trueVal()
val textModel: Val<ITextModel?> = val textModel: Val<ITextModel?> =
questEditorStore.currentQuest.map(inlineStackArgs) { quest, inlineArgs -> map(questEditorStore.currentQuest, inlineStackArgs) { quest, inlineArgs ->
_textModel?.dispose() _textModel?.dispose()
_textModel = _textModel =
if (quest == null) null if (quest == null) null
else { else {
val assembly = disassemble(quest.byteCodeIr, inlineArgs) val assembly = disassemble(quest.bytecodeIr, inlineArgs)
createModel(assembly.joinToString("\n"), ASM_LANG_ID) createModel(assembly.joinToString("\n"), ASM_LANG_ID)
} }

View File

@ -1,8 +1,11 @@
package world.phantasmal.web.questEditor.stores package world.phantasmal.web.questEditor.stores
import kotlinx.coroutines.CoroutineScope
import mu.KotlinLogging 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.PwToolType
import world.phantasmal.web.core.actions.Action import world.phantasmal.web.core.actions.Action
import world.phantasmal.web.core.stores.UiStore import world.phantasmal.web.core.stores.UiStore
@ -15,10 +18,9 @@ import world.phantasmal.webui.stores.Store
private val logger = KotlinLogging.logger {} private val logger = KotlinLogging.logger {}
class QuestEditorStore( class QuestEditorStore(
scope: CoroutineScope,
private val uiStore: UiStore, private val uiStore: UiStore,
private val areaStore: AreaStore, private val areaStore: AreaStore,
) : Store(scope) { ) : Store() {
private val _currentQuest = mutableVal<QuestModel?>(null) private val _currentQuest = mutableVal<QuestModel?>(null)
private val _currentArea = mutableVal<AreaModel?>(null) private val _currentArea = mutableVal<AreaModel?>(null)
private val _selectedWave = mutableVal<WaveModel?>(null) private val _selectedWave = mutableVal<WaveModel?>(null)
@ -52,7 +54,23 @@ class QuestEditorStore(
init { init {
observe(uiStore.currentTool) { tool -> observe(uiStore.currentTool) { tool ->
if (tool == PwToolType.QuestEditor) { 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) { fun executeAction(action: Action) {
pushAction(action)
action.execute()
}
fun pushAction(action: Action) {
require(questEditingEnabled.value) { require(questEditingEnabled.value) {
val reason = when { val reason = when {
currentQuest.value == null -> " (no current quest)" currentQuest.value == null -> " (no current quest)"
@ -151,6 +174,6 @@ class QuestEditorStore(
} }
"Quest editing is disabled at the moment$reason." "Quest editing is disabled at the moment$reason."
} }
mainUndo.push(action).execute() mainUndo.push(action)
} }
} }

View File

@ -1,6 +1,5 @@
package world.phantasmal.web.questEditor.widgets package world.phantasmal.web.questEditor.widgets
import kotlinx.coroutines.CoroutineScope
import org.w3c.dom.Node import org.w3c.dom.Node
import world.phantasmal.core.disposable.disposable import world.phantasmal.core.disposable.disposable
import world.phantasmal.web.externals.monacoEditor.IStandaloneCodeEditor 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.obj
import world.phantasmal.webui.widgets.Widget import world.phantasmal.webui.widgets.Widget
class AssemblyEditorWidget( class AssemblyEditorWidget(private val ctrl: AssemblyEditorController) : Widget() {
scope: CoroutineScope,
private val ctrl: AssemblyEditorController,
) : Widget(scope) {
private lateinit var editor: IStandaloneCodeEditor private lateinit var editor: IStandaloneCodeEditor
override fun Node.createElement() = override fun Node.createElement() =

View File

@ -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<String, EventData> = 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)

View File

@ -1,6 +1,5 @@
package world.phantasmal.web.questEditor.widgets package world.phantasmal.web.questEditor.widgets
import kotlinx.coroutines.CoroutineScope
import org.w3c.dom.Node import org.w3c.dom.Node
import world.phantasmal.web.core.widgets.UnavailableWidget import world.phantasmal.web.core.widgets.UnavailableWidget
import world.phantasmal.web.questEditor.controllers.EntityInfoController 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.DoubleInput
import world.phantasmal.webui.widgets.Widget import world.phantasmal.webui.widgets.Widget
class EntityInfoWidget( class EntityInfoWidget(private val ctrl: EntityInfoController) : Widget(enabled = ctrl.enabled) {
scope: CoroutineScope,
private val ctrl: EntityInfoController,
) : Widget(scope, enabled = ctrl.enabled) {
override fun Node.createElement() = override fun Node.createElement() =
div { div {
className = "pw-quest-editor-entity-info" className = "pw-quest-editor-entity-info"
@ -45,7 +41,6 @@ class EntityInfoWidget(
th { className = COORD_CLASS; textContent = "X:" } th { className = COORD_CLASS; textContent = "X:" }
td { td {
addChild(DoubleInput( addChild(DoubleInput(
this@EntityInfoWidget.scope,
enabled = ctrl.enabled, enabled = ctrl.enabled,
value = ctrl.posX, value = ctrl.posX,
onChange = ctrl::setPosX, onChange = ctrl::setPosX,
@ -57,7 +52,6 @@ class EntityInfoWidget(
th { className = COORD_CLASS; textContent = "Y:" } th { className = COORD_CLASS; textContent = "Y:" }
td { td {
addChild(DoubleInput( addChild(DoubleInput(
this@EntityInfoWidget.scope,
enabled = ctrl.enabled, enabled = ctrl.enabled,
value = ctrl.posY, value = ctrl.posY,
onChange = ctrl::setPosY, onChange = ctrl::setPosY,
@ -69,7 +63,6 @@ class EntityInfoWidget(
th { className = COORD_CLASS; textContent = "Z:" } th { className = COORD_CLASS; textContent = "Z:" }
td { td {
addChild(DoubleInput( addChild(DoubleInput(
this@EntityInfoWidget.scope,
enabled = ctrl.enabled, enabled = ctrl.enabled,
value = ctrl.posZ, value = ctrl.posZ,
onChange = ctrl::setPosZ, onChange = ctrl::setPosZ,
@ -84,7 +77,6 @@ class EntityInfoWidget(
th { className = COORD_CLASS; textContent = "X:" } th { className = COORD_CLASS; textContent = "X:" }
td { td {
addChild(DoubleInput( addChild(DoubleInput(
this@EntityInfoWidget.scope,
enabled = ctrl.enabled, enabled = ctrl.enabled,
value = ctrl.rotX, value = ctrl.rotX,
onChange = ctrl::setRotX, onChange = ctrl::setRotX,
@ -96,7 +88,6 @@ class EntityInfoWidget(
th { className = COORD_CLASS; textContent = "Y:" } th { className = COORD_CLASS; textContent = "Y:" }
td { td {
addChild(DoubleInput( addChild(DoubleInput(
this@EntityInfoWidget.scope,
enabled = ctrl.enabled, enabled = ctrl.enabled,
value = ctrl.rotY, value = ctrl.rotY,
onChange = ctrl::setRotY, onChange = ctrl::setRotY,
@ -108,7 +99,6 @@ class EntityInfoWidget(
th { className = COORD_CLASS; textContent = "Z:" } th { className = COORD_CLASS; textContent = "Z:" }
td { td {
addChild(DoubleInput( addChild(DoubleInput(
this@EntityInfoWidget.scope,
enabled = ctrl.enabled, enabled = ctrl.enabled,
value = ctrl.rotZ, value = ctrl.rotZ,
onChange = ctrl::setRotZ, onChange = ctrl::setRotZ,
@ -118,7 +108,6 @@ class EntityInfoWidget(
} }
} }
addChild(UnavailableWidget( addChild(UnavailableWidget(
scope,
visible = ctrl.unavailable, visible = ctrl.unavailable,
message = "No entity selected.", message = "No entity selected.",
)) ))

View File

@ -1,17 +1,19 @@
package world.phantasmal.web.questEditor.widgets package world.phantasmal.web.questEditor.widgets
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch
import org.w3c.dom.Node import org.w3c.dom.Node
import world.phantasmal.lib.fileFormats.quest.EntityType
import world.phantasmal.web.questEditor.controllers.EntityListController 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.div
import world.phantasmal.webui.dom.img import world.phantasmal.webui.dom.img
import world.phantasmal.webui.dom.span import world.phantasmal.webui.dom.span
import world.phantasmal.webui.widgets.Widget import world.phantasmal.webui.widgets.Widget
class EntityListWidget( class EntityListWidget(
scope: CoroutineScope,
private val ctrl: EntityListController, private val ctrl: EntityListController,
) : Widget(scope, enabled = ctrl.enabled) { private val entityImageRenderer: EntityImageRenderer,
) : Widget(enabled = ctrl.enabled) {
override fun Node.createElement() = override fun Node.createElement() =
div { div {
className = "pw-quest-editor-entity-list" className = "pw-quest-editor-entity-list"
@ -20,23 +22,38 @@ class EntityListWidget(
div { div {
className = "pw-quest-editor-entity-list-inner" className = "pw-quest-editor-entity-list-inner"
bindChildrenTo(ctrl.entities) { entityType, index -> bindChildWidgetsTo(ctrl.entities) { entityType, _ ->
div { EntityListEntityWidget(entityType)
className = "pw-quest-editor-entity-list-entity"
img {
width = 100
height = 100
}
span {
textContent = entityType.simpleName
}
}
} }
} }
} }
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 { companion object {
init { init {
@Suppress("CssUnusedSymbol") @Suppress("CssUnusedSymbol")

View File

@ -1,6 +1,5 @@
package world.phantasmal.web.questEditor.widgets package world.phantasmal.web.questEditor.widgets
import kotlinx.coroutines.CoroutineScope
import org.w3c.dom.Node import org.w3c.dom.Node
import world.phantasmal.web.core.widgets.UnavailableWidget import world.phantasmal.web.core.widgets.UnavailableWidget
import world.phantasmal.web.questEditor.controllers.NpcCountsController import world.phantasmal.web.questEditor.controllers.NpcCountsController
@ -8,9 +7,8 @@ import world.phantasmal.webui.dom.*
import world.phantasmal.webui.widgets.Widget import world.phantasmal.webui.widgets.Widget
class NpcCountsWidget( class NpcCountsWidget(
scope: CoroutineScope,
private val ctrl: NpcCountsController, private val ctrl: NpcCountsController,
) : Widget(scope) { ) : Widget() {
override fun Node.createElement() = override fun Node.createElement() =
div { div {
className = "pw-quest-editor-npc-counts" className = "pw-quest-editor-npc-counts"
@ -26,7 +24,6 @@ class NpcCountsWidget(
} }
} }
addChild(UnavailableWidget( addChild(UnavailableWidget(
scope,
visible = ctrl.unavailable, visible = ctrl.unavailable,
message = "No quest loaded." message = "No quest loaded."
)) ))

View File

@ -1,9 +1,7 @@
package world.phantasmal.web.questEditor.widgets package world.phantasmal.web.questEditor.widgets
import kotlinx.coroutines.CoroutineScope
import world.phantasmal.web.questEditor.rendering.QuestRenderer import world.phantasmal.web.questEditor.rendering.QuestRenderer
class QuestEditorRendererWidget( class QuestEditorRendererWidget(
scope: CoroutineScope,
renderer: QuestRenderer, renderer: QuestRenderer,
) : QuestRendererWidget(scope, renderer) ) : QuestRendererWidget(renderer)

View File

@ -1,6 +1,5 @@
package world.phantasmal.web.questEditor.widgets package world.phantasmal.web.questEditor.widgets
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.w3c.dom.Node import org.w3c.dom.Node
import world.phantasmal.lib.fileFormats.quest.Episode import world.phantasmal.lib.fileFormats.quest.Episode
@ -10,25 +9,19 @@ import world.phantasmal.webui.dom.Icon
import world.phantasmal.webui.dom.div import world.phantasmal.webui.dom.div
import world.phantasmal.webui.widgets.* import world.phantasmal.webui.widgets.*
class QuestEditorToolbarWidget( class QuestEditorToolbarWidget(private val ctrl: QuestEditorToolbarController) : Widget() {
scope: CoroutineScope,
private val ctrl: QuestEditorToolbarController,
) : Widget(scope) {
override fun Node.createElement() = override fun Node.createElement() =
div { div {
className = "pw-quest-editor-toolbar" className = "pw-quest-editor-toolbar"
addChild(Toolbar( addChild(Toolbar(
scope,
children = listOf( children = listOf(
Button( Button(
scope,
text = "New quest", text = "New quest",
iconLeft = Icon.NewFile, iconLeft = Icon.NewFile,
onClick = { scope.launch { ctrl.createNewQuest(Episode.I) } }, onClick = { scope.launch { ctrl.createNewQuest(Episode.I) } },
), ),
FileButton( FileButton(
scope,
text = "Open file...", text = "Open file...",
tooltip = value("Open a quest file (Ctrl-O)"), tooltip = value("Open a quest file (Ctrl-O)"),
iconLeft = Icon.File, iconLeft = Icon.File,
@ -37,7 +30,6 @@ class QuestEditorToolbarWidget(
filesSelected = { files -> scope.launch { ctrl.openFiles(files) } }, filesSelected = { files -> scope.launch { ctrl.openFiles(files) } },
), ),
Button( Button(
scope,
text = "Undo", text = "Undo",
iconLeft = Icon.Undo, iconLeft = Icon.Undo,
enabled = ctrl.undoEnabled, enabled = ctrl.undoEnabled,
@ -45,7 +37,6 @@ class QuestEditorToolbarWidget(
onClick = { ctrl.undo() }, onClick = { ctrl.undo() },
), ),
Button( Button(
scope,
text = "Redo", text = "Redo",
iconLeft = Icon.Redo, iconLeft = Icon.Redo,
enabled = ctrl.redoEnabled, enabled = ctrl.redoEnabled,
@ -53,7 +44,6 @@ class QuestEditorToolbarWidget(
onClick = { ctrl.redo() }, onClick = { ctrl.redo() },
), ),
Select( Select(
scope,
enabled = ctrl.areaSelectEnabled, enabled = ctrl.areaSelectEnabled,
itemsVal = ctrl.areas, itemsVal = ctrl.areas,
itemToString = { it.label }, itemToString = { it.label },

View File

@ -1,6 +1,5 @@
package world.phantasmal.web.questEditor.widgets package world.phantasmal.web.questEditor.widgets
import kotlinx.coroutines.CoroutineScope
import org.w3c.dom.Node import org.w3c.dom.Node
import world.phantasmal.web.core.widgets.DockWidget import world.phantasmal.web.core.widgets.DockWidget
import world.phantasmal.web.questEditor.controllers.QuestEditorController 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. * Takes ownership of the widgets created by the given creation functions.
*/ */
class QuestEditorWidget( class QuestEditorWidget(
scope: CoroutineScope,
private val ctrl: QuestEditorController, private val ctrl: QuestEditorController,
private val createToolbar: (CoroutineScope) -> QuestEditorToolbarWidget, private val createToolbar: () -> QuestEditorToolbarWidget,
private val createQuestInfoWidget: (CoroutineScope) -> QuestInfoWidget, private val createQuestInfoWidget: () -> QuestInfoWidget,
private val createNpcCountsWidget: (CoroutineScope) -> NpcCountsWidget, private val createNpcCountsWidget: () -> NpcCountsWidget,
private val createEntityInfoWidget: (CoroutineScope) -> EntityInfoWidget, private val createEntityInfoWidget: () -> EntityInfoWidget,
private val createQuestRendererWidget: (CoroutineScope) -> QuestRendererWidget, private val createQuestRendererWidget: () -> QuestRendererWidget,
private val createAssemblyEditorWidget: (CoroutineScope) -> AssemblyEditorWidget, private val createAssemblyEditorWidget: () -> AssemblyEditorWidget,
private val createNpcListWidget: (CoroutineScope) -> EntityListWidget, private val createNpcListWidget: () -> EntityListWidget,
private val createObjectListWidget: (CoroutineScope) -> EntityListWidget, private val createObjectListWidget: () -> EntityListWidget,
) : Widget(scope) { ) : Widget() {
override fun Node.createElement() = override fun Node.createElement() =
div { div {
className = "pw-quest-editor-quest-editor" className = "pw-quest-editor-quest-editor"
addChild(createToolbar(scope)) addChild(createToolbar())
addChild(DockWidget( addChild(DockWidget(
scope,
ctrl = ctrl, ctrl = ctrl,
createWidget = { scope, id -> createWidget = { id ->
when (id) { when (id) {
QUEST_INFO_WIDGET_ID -> createQuestInfoWidget(scope) QUEST_INFO_WIDGET_ID -> createQuestInfoWidget()
NPC_COUNTS_WIDGET_ID -> createNpcCountsWidget(scope) NPC_COUNTS_WIDGET_ID -> createNpcCountsWidget()
ENTITY_INFO_WIDGET_ID -> createEntityInfoWidget(scope) ENTITY_INFO_WIDGET_ID -> createEntityInfoWidget()
QUEST_RENDERER_WIDGET_ID -> createQuestRendererWidget(scope) QUEST_RENDERER_WIDGET_ID -> createQuestRendererWidget()
ASSEMBLY_EDITOR_WIDGET_ID -> createAssemblyEditorWidget(scope) ASSEMBLY_EDITOR_WIDGET_ID -> createAssemblyEditorWidget()
NPC_LIST_WIDGET_ID -> createNpcListWidget(scope) NPC_LIST_WIDGET_ID -> createNpcListWidget()
OBJECT_LIST_WIDGET_ID -> createObjectListWidget(scope) OBJECT_LIST_WIDGET_ID -> createObjectListWidget()
EVENTS_WIDGET_ID -> null // TODO: EventsWidget. EVENTS_WIDGET_ID -> null // TODO: EventsWidget.
else -> null else -> null
} }

View File

@ -1,6 +1,5 @@
package world.phantasmal.web.questEditor.widgets package world.phantasmal.web.questEditor.widgets
import kotlinx.coroutines.CoroutineScope
import org.w3c.dom.Node import org.w3c.dom.Node
import world.phantasmal.web.core.widgets.UnavailableWidget import world.phantasmal.web.core.widgets.UnavailableWidget
import world.phantasmal.web.questEditor.controllers.QuestInfoController 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.TextInput
import world.phantasmal.webui.widgets.Widget import world.phantasmal.webui.widgets.Widget
class QuestInfoWidget( class QuestInfoWidget(private val ctrl: QuestInfoController) : Widget(enabled = ctrl.enabled) {
scope: CoroutineScope,
private val ctrl: QuestInfoController,
) : Widget(scope, enabled = ctrl.enabled) {
override fun Node.createElement() = override fun Node.createElement() =
div { div {
className = "pw-quest-editor-quest-info" className = "pw-quest-editor-quest-info"
@ -30,7 +26,6 @@ class QuestInfoWidget(
th { textContent = "ID:" } th { textContent = "ID:" }
td { td {
addChild(IntInput( addChild(IntInput(
this@QuestInfoWidget.scope,
enabled = ctrl.enabled, enabled = ctrl.enabled,
value = ctrl.id, value = ctrl.id,
onChange = ctrl::setId, onChange = ctrl::setId,
@ -43,7 +38,6 @@ class QuestInfoWidget(
th { textContent = "Name:" } th { textContent = "Name:" }
td { td {
addChild(TextInput( addChild(TextInput(
this@QuestInfoWidget.scope,
enabled = ctrl.enabled, enabled = ctrl.enabled,
value = ctrl.name, value = ctrl.name,
onChange = ctrl::setName, onChange = ctrl::setName,
@ -61,7 +55,6 @@ class QuestInfoWidget(
td { td {
colSpan = 2 colSpan = 2
addChild(TextArea( addChild(TextArea(
this@QuestInfoWidget.scope,
enabled = ctrl.enabled, enabled = ctrl.enabled,
value = ctrl.shortDescription, value = ctrl.shortDescription,
onChange = ctrl::setShortDescription, onChange = ctrl::setShortDescription,
@ -82,7 +75,6 @@ class QuestInfoWidget(
td { td {
colSpan = 2 colSpan = 2
addChild(TextArea( addChild(TextArea(
this@QuestInfoWidget.scope,
enabled = ctrl.enabled, enabled = ctrl.enabled,
value = ctrl.longDescription, value = ctrl.longDescription,
onChange = ctrl::setLongDescription, onChange = ctrl::setLongDescription,
@ -95,7 +87,6 @@ class QuestInfoWidget(
} }
} }
addChild(UnavailableWidget( addChild(UnavailableWidget(
scope,
visible = ctrl.unavailable, visible = ctrl.unavailable,
message = "No quest loaded." message = "No quest loaded."
)) ))

View File

@ -1,6 +1,5 @@
package world.phantasmal.web.questEditor.widgets package world.phantasmal.web.questEditor.widgets
import kotlinx.coroutines.CoroutineScope
import org.w3c.dom.Node import org.w3c.dom.Node
import world.phantasmal.web.core.widgets.RendererWidget import world.phantasmal.web.core.widgets.RendererWidget
import world.phantasmal.web.questEditor.rendering.QuestRenderer import world.phantasmal.web.questEditor.rendering.QuestRenderer
@ -8,14 +7,13 @@ import world.phantasmal.webui.dom.div
import world.phantasmal.webui.widgets.Widget import world.phantasmal.webui.widgets.Widget
abstract class QuestRendererWidget( abstract class QuestRendererWidget(
scope: CoroutineScope,
private val renderer: QuestRenderer, private val renderer: QuestRenderer,
) : Widget(scope) { ) : Widget() {
override fun Node.createElement() = override fun Node.createElement() =
div { div {
className = "pw-quest-editor-quest-renderer" className = "pw-quest-editor-quest-renderer"
addChild(RendererWidget(scope, renderer)) addChild(RendererWidget(renderer))
} }
companion object { companion object {

View File

@ -1,6 +1,5 @@
package world.phantasmal.web.viewer package world.phantasmal.web.viewer
import kotlinx.coroutines.CoroutineScope
import org.w3c.dom.HTMLCanvasElement import org.w3c.dom.HTMLCanvasElement
import world.phantasmal.web.core.PwTool import world.phantasmal.web.core.PwTool
import world.phantasmal.web.core.PwToolType import world.phantasmal.web.core.PwToolType
@ -21,9 +20,9 @@ class Viewer(
) : DisposableContainer(), PwTool { ) : DisposableContainer(), PwTool {
override val toolType = PwToolType.Viewer override val toolType = PwToolType.Viewer
override fun initialize(scope: CoroutineScope): Widget { override fun initialize(): Widget {
// Stores // Stores
val viewerStore = addDisposable(ViewerStore(scope)) val viewerStore = addDisposable(ViewerStore())
// Controllers // Controllers
val viewerController = addDisposable(ViewerController()) val viewerController = addDisposable(ViewerController())
@ -39,11 +38,10 @@ class Viewer(
// Main Widget // Main Widget
return ViewerWidget( return ViewerWidget(
scope,
viewerController, viewerController,
{ s -> ViewerToolbar(s, viewerToolbarController) }, { ViewerToolbar(viewerToolbarController) },
{ s -> RendererWidget(s, meshRenderer) }, { RendererWidget(meshRenderer) },
{ s -> RendererWidget(s, textureRenderer) }, { RendererWidget(textureRenderer) },
) )
} }
} }

View File

@ -7,10 +7,11 @@ import world.phantasmal.core.PwResult
import world.phantasmal.core.Severity import world.phantasmal.core.Severity
import world.phantasmal.core.Success import world.phantasmal.core.Success
import world.phantasmal.lib.Endianness 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.cursor.cursor
import world.phantasmal.lib.fileFormats.ninja.parseNj import world.phantasmal.lib.fileFormats.ninja.*
import world.phantasmal.lib.fileFormats.ninja.parseXj import world.phantasmal.lib.fileFormats.parseAfs
import world.phantasmal.lib.fileFormats.ninja.parseXvm
import world.phantasmal.observable.value.Val import world.phantasmal.observable.value.Val
import world.phantasmal.observable.value.mutableVal import world.phantasmal.observable.value.mutableVal
import world.phantasmal.web.viewer.store.ViewerStore import world.phantasmal.web.viewer.store.ViewerStore
@ -38,56 +39,71 @@ class ViewerToolbarController(private val store: ViewerStore) : Controller() {
var success = false var success = false
try { try {
var modelFound = false val kindsFound = mutableSetOf<FileKind>()
var textureFound = false
for (file in files) { for (file in files) {
when (file.extension()?.toLowerCase()) { val extension = file.extension()?.toLowerCase()
"nj" -> {
if (modelFound) continue
modelFound = true val kind = when (extension) {
val njResult = parseNj(readFile(file).cursor(Endianness.Little)) "nj", "xj" -> FileKind.Model
result.addResult(njResult) "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) { if (njResult is Success) {
store.setCurrentNinjaObject(njResult.value.firstOrNull()) store.setCurrentNinjaObject(njResult.value.firstOrNull())
success = true
} }
} }
"xj" -> { "xj" -> {
if (modelFound) continue val xjResult = parseXj(cursor)
fileResult = xjResult
modelFound = true
val xjResult = parseXj(readFile(file).cursor(Endianness.Little))
result.addResult(xjResult)
if (xjResult is Success) { if (xjResult is Success) {
store.setCurrentNinjaObject(xjResult.value.firstOrNull()) store.setCurrentNinjaObject(xjResult.value.firstOrNull())
success = true }
}
"afs" -> {
val afsResult = parseAfsTextures(cursor)
fileResult = afsResult
if (afsResult is Success) {
store.setCurrentTextures(afsResult.value)
} }
} }
"xvm" -> { "xvm" -> {
if (textureFound) continue val xvmResult = parseXvm(cursor)
fileResult = xvmResult
textureFound = true
val xvmResult = parseXvm(readFile(file).cursor(Endianness.Little))
result.addResult(xvmResult)
if (xvmResult is Success) { if (xvmResult is Success) {
store.setCurrentTextures(xvmResult.value.textures) store.setCurrentTextures(xvmResult.value.textures)
success = true
} }
} }
}
else -> { fileResult?.let(result::addResult)
result.addProblem(
Severity.Error, if (fileResult is Success<*>) {
"""File "${file.name}" has an unsupported file type.""" success = true
) kindsFound.add(kind)
}
} }
} }
} catch (e: Exception) { } catch (e: Exception) {
@ -105,4 +121,47 @@ class ViewerToolbarController(private val store: ViewerStore) : Controller() {
_result.value = result _result.value = result
_resultDialogVisible.value = result != null && result.problems.isNotEmpty() _resultDialogVisible.value = result != null && result.problems.isNotEmpty()
} }
private fun parseAfsTextures(cursor: Cursor): PwResult<List<XvrTexture>> {
val result = PwResult.build<List<XvrTexture>>(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<XvrTexture> = 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
}
} }

View File

@ -1,5 +1,6 @@
package world.phantasmal.web.viewer.rendering package world.phantasmal.web.viewer.rendering
import mu.KotlinLogging
import org.w3c.dom.HTMLCanvasElement import org.w3c.dom.HTMLCanvasElement
import world.phantasmal.lib.fileFormats.ninja.XvrTexture import world.phantasmal.lib.fileFormats.ninja.XvrTexture
import world.phantasmal.web.core.rendering.* import world.phantasmal.web.core.rendering.*
@ -12,6 +13,8 @@ import kotlin.math.ceil
import kotlin.math.max import kotlin.math.max
import kotlin.math.sqrt import kotlin.math.sqrt
private val logger = KotlinLogging.logger {}
class TextureRenderer( class TextureRenderer(
store: ViewerStore, store: ViewerStore,
createThreeRenderer: (HTMLCanvasElement) -> DisposableThreeRenderer, createThreeRenderer: (HTMLCanvasElement) -> DisposableThreeRenderer,
@ -36,7 +39,8 @@ class TextureRenderer(
context.canvas, context.canvas,
context.camera, context.camera,
Vector3(0.0, 0.0, 5.0), Vector3(0.0, 0.0, 5.0),
screenSpacePanning = true screenSpacePanning = true,
enableRotate = false,
)) ))
init { init {
@ -71,11 +75,23 @@ class TextureRenderer(
var cell = 0 var cell = 0
meshes = textures.map { xvr -> 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( val quad = Mesh(
createQuad(x, y, xvr.width, xvr.height), createQuad(x, y, xvr.width, xvr.height),
MeshBasicMaterial(obj { MeshBasicMaterial(obj {
map = xvrTextureToThree(xvr, filter = NearestFilter) if (texture == null) {
transparent = true color = Color(0xFF00FF)
} else {
map = texture
transparent = true
}
}) })
) )
context.scene.add(quad) context.scene.add(quad)
@ -96,7 +112,7 @@ class TextureRenderer(
width.toDouble(), width.toDouble(),
height.toDouble(), height.toDouble(),
widthSegments = 1.0, widthSegments = 1.0,
heightSegments = 1.0 heightSegments = 1.0,
) )
quad.faceVertexUvs = arrayOf( quad.faceVertexUvs = arrayOf(
arrayOf( arrayOf(

View File

@ -1,6 +1,5 @@
package world.phantasmal.web.viewer.store package world.phantasmal.web.viewer.store
import kotlinx.coroutines.CoroutineScope
import world.phantasmal.lib.fileFormats.ninja.NinjaObject import world.phantasmal.lib.fileFormats.ninja.NinjaObject
import world.phantasmal.lib.fileFormats.ninja.XvrTexture import world.phantasmal.lib.fileFormats.ninja.XvrTexture
import world.phantasmal.observable.value.Val 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.observable.value.mutableVal
import world.phantasmal.webui.stores.Store import world.phantasmal.webui.stores.Store
class ViewerStore(scope: CoroutineScope) : Store(scope) { class ViewerStore() : Store() {
private val _currentNinjaObject = mutableVal<NinjaObject<*>?>(null) private val _currentNinjaObject = mutableVal<NinjaObject<*>?>(null)
private val _currentTextures = mutableListVal<XvrTexture>(mutableListOf()) private val _currentTextures = mutableListVal<XvrTexture>(mutableListOf())

View File

@ -1,6 +1,5 @@
package world.phantasmal.web.viewer.widgets package world.phantasmal.web.viewer.widgets
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.w3c.dom.Node import org.w3c.dom.Node
import world.phantasmal.web.viewer.controller.ViewerToolbarController 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.Toolbar
import world.phantasmal.webui.widgets.Widget import world.phantasmal.webui.widgets.Widget
class ViewerToolbar( class ViewerToolbar(private val ctrl: ViewerToolbarController) : Widget() {
scope: CoroutineScope,
private val ctrl: ViewerToolbarController,
) : Widget(scope) {
override fun Node.createElement() = override fun Node.createElement() =
div { div {
className = "pw-viewer-toolbar" className = "pw-viewer-toolbar"
addChild(Toolbar( addChild(Toolbar(
scope,
children = listOf( children = listOf(
FileButton( FileButton(
scope,
text = "Open file...", text = "Open file...",
iconLeft = Icon.File, iconLeft = Icon.File,
accept = ".afs, .nj, .njm, .xj, .xvm", accept = ".afs, .nj, .njm, .xj, .xvm",
@ -33,7 +27,6 @@ class ViewerToolbar(
) )
)) ))
addDisposable(ResultDialog( addDisposable(ResultDialog(
scope,
visible = ctrl.resultDialogVisible, visible = ctrl.resultDialogVisible,
result = ctrl.result, result = ctrl.result,
message = ctrl.resultMessage, message = ctrl.resultMessage,

View File

@ -1,6 +1,5 @@
package world.phantasmal.web.viewer.widgets package world.phantasmal.web.viewer.widgets
import kotlinx.coroutines.CoroutineScope
import org.w3c.dom.Node import org.w3c.dom.Node
import world.phantasmal.web.viewer.controller.ViewerController import world.phantasmal.web.viewer.controller.ViewerController
import world.phantasmal.web.viewer.controller.ViewerTab 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]. * Takes ownership of the widget returned by [createToolbar].
*/ */
class ViewerWidget( class ViewerWidget(
scope: CoroutineScope,
private val ctrl: ViewerController, private val ctrl: ViewerController,
private val createToolbar: (CoroutineScope) -> Widget, private val createToolbar: () -> Widget,
private val createMeshWidget: (CoroutineScope) -> Widget, private val createMeshWidget: () -> Widget,
private val createTextureWidget: (CoroutineScope) -> Widget, private val createTextureWidget: () -> Widget,
) : Widget(scope) { ) : Widget() {
override fun Node.createElement() = override fun Node.createElement() =
div { div {
className = "pw-viewer-viewer" className = "pw-viewer-viewer"
addChild(createToolbar(scope)) addChild(createToolbar())
addChild(TabContainer(scope, ctrl = ctrl, createWidget = { scope, tab -> addChild(TabContainer(ctrl = ctrl, createWidget = { tab ->
when (tab) { when (tab) {
ViewerTab.Mesh -> createMeshWidget(scope) ViewerTab.Mesh -> createMeshWidget()
ViewerTab.Texture -> createTextureWidget(scope) ViewerTab.Texture -> createTextureWidget()
} }
})) }))
} }

View File

@ -17,7 +17,6 @@ class ApplicationTests : WebTestSuite() {
disposer.add( disposer.add(
Application( Application(
scope,
rootElement = document.body!!, rootElement = document.body!!,
assetLoader = components.assetLoader, assetLoader = components.assetLoader,
applicationUrl = appUrl, applicationUrl = appUrl,

View File

@ -42,7 +42,7 @@ class PathAwareTabControllerTests : WebTestSuite() {
@Test @Test
fun applicationUrl_changes_when_switch_to_tool_with_tabs() = test { fun applicationUrl_changes_when_switch_to_tool_with_tabs() = test {
val appUrl = TestApplicationUrl("/") val appUrl = TestApplicationUrl("/")
val uiStore = disposer.add(UiStore(scope, appUrl)) val uiStore = disposer.add(UiStore(appUrl))
disposer.add( disposer.add(
PathAwareTabController(uiStore, PwToolType.HuntOptimizer, listOf( PathAwareTabController(uiStore, PwToolType.HuntOptimizer, listOf(
@ -71,7 +71,7 @@ class PathAwareTabControllerTests : WebTestSuite() {
block: (PathAwareTabController<PathAwareTab>, applicationUrl: TestApplicationUrl) -> Unit, block: (PathAwareTabController<PathAwareTab>, applicationUrl: TestApplicationUrl) -> Unit,
) { ) {
val applicationUrl = TestApplicationUrl("/${PwToolType.HuntOptimizer.slug}/b") val applicationUrl = TestApplicationUrl("/${PwToolType.HuntOptimizer.slug}/b")
val uiStore = disposer.add(UiStore(scope, applicationUrl)) val uiStore = disposer.add(UiStore(applicationUrl))
uiStore.setCurrentTool(PwToolType.HuntOptimizer) uiStore.setCurrentTool(PwToolType.HuntOptimizer)
val ctrl = disposer.add( val ctrl = disposer.add(

View File

@ -11,7 +11,7 @@ class UiStoreTests : WebTestSuite() {
@Test @Test
fun applicationUrl_is_initialized_correctly() = test { fun applicationUrl_is_initialized_correctly() = test {
val applicationUrl = TestApplicationUrl("/") 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, uiStore.currentTool.value)
assertEquals("/${PwToolType.Viewer.slug}", applicationUrl.url.value) assertEquals("/${PwToolType.Viewer.slug}", applicationUrl.url.value)
@ -20,7 +20,7 @@ class UiStoreTests : WebTestSuite() {
@Test @Test
fun applicationUrl_changes_when_tool_changes() = test { fun applicationUrl_changes_when_tool_changes() = test {
val applicationUrl = TestApplicationUrl("/") val applicationUrl = TestApplicationUrl("/")
val uiStore = disposer.add(UiStore(scope, applicationUrl)) val uiStore = disposer.add(UiStore(applicationUrl))
PwToolType.values().forEach { tool -> PwToolType.values().forEach { tool ->
uiStore.setCurrentTool(tool) uiStore.setCurrentTool(tool)
@ -33,7 +33,7 @@ class UiStoreTests : WebTestSuite() {
@Test @Test
fun applicationUrl_changes_when_path_changes() = test { fun applicationUrl_changes_when_path_changes() = test {
val applicationUrl = TestApplicationUrl("/") 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, uiStore.currentTool.value)
assertEquals("/${PwToolType.Viewer.slug}", applicationUrl.url.value) assertEquals("/${PwToolType.Viewer.slug}", applicationUrl.url.value)
@ -48,7 +48,7 @@ class UiStoreTests : WebTestSuite() {
@Test @Test
fun currentTool_and_path_change_when_applicationUrl_changes() = test { fun currentTool_and_path_change_when_applicationUrl_changes() = test {
val applicationUrl = TestApplicationUrl("/") val applicationUrl = TestApplicationUrl("/")
val uiStore = disposer.add(UiStore(scope, applicationUrl)) val uiStore = disposer.add(UiStore(applicationUrl))
PwToolType.values().forEach { tool -> PwToolType.values().forEach { tool ->
listOf("/a", "/b", "/c").forEach { path -> listOf("/a", "/b", "/c").forEach { path ->
@ -63,7 +63,7 @@ class UiStoreTests : WebTestSuite() {
@Test @Test
fun browser_navigation_stack_is_manipulated_correctly() = test { fun browser_navigation_stack_is_manipulated_correctly() = test {
val appUrl = TestApplicationUrl("/") val appUrl = TestApplicationUrl("/")
val uiStore = disposer.add(UiStore(scope, appUrl)) val uiStore = disposer.add(UiStore(appUrl))
assertEquals("/${uiStore.defaultTool.slug}", appUrl.url.value) assertEquals("/${uiStore.defaultTool.slug}", appUrl.url.value)

View File

@ -10,9 +10,9 @@ class HuntOptimizerTests : WebTestSuite() {
@Test @Test
fun initialization_and_shutdown_should_succeed_without_throwing() = test { fun initialization_and_shutdown_should_succeed_without_throwing() = test {
val uiStore = val uiStore =
disposer.add(UiStore(scope, TestApplicationUrl("/${PwToolType.HuntOptimizer}"))) disposer.add(UiStore(TestApplicationUrl("/${PwToolType.HuntOptimizer}")))
val huntOptimizer = disposer.add(HuntOptimizer(components.assetLoader, uiStore)) val huntOptimizer = disposer.add(HuntOptimizer(components.assetLoader, uiStore))
disposer.add(huntOptimizer.initialize(scope)) disposer.add(huntOptimizer.initialize())
} }
} }

View File

@ -9,6 +9,6 @@ class QuestEditorTests : WebTestSuite() {
val questEditor = disposer.add( val questEditor = disposer.add(
QuestEditor(components.assetLoader, components.uiStore, components.createThreeRenderer) QuestEditor(components.assetLoader, components.uiStore, components.createThreeRenderer)
) )
disposer.add(questEditor.initialize(scope)) disposer.add(questEditor.initialize())
} }
} }

View File

@ -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) {}
}

View File

@ -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() {
}
}

View File

@ -13,6 +13,7 @@ import world.phantasmal.web.core.loading.AssetLoader
import world.phantasmal.web.core.rendering.DisposableThreeRenderer import world.phantasmal.web.core.rendering.DisposableThreeRenderer
import world.phantasmal.web.core.stores.ApplicationUrl import world.phantasmal.web.core.stores.ApplicationUrl
import world.phantasmal.web.core.stores.UiStore 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.AreaAssetLoader
import world.phantasmal.web.questEditor.loading.QuestLoader import world.phantasmal.web.questEditor.loading.QuestLoader
import world.phantasmal.web.questEditor.stores.AreaStore 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 assetLoader: AssetLoader by default { AssetLoader(httpClient, basePath = "/assets") }
var areaAssetLoader: AreaAssetLoader by default { 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 // 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 { var questEditorStore: QuestEditorStore by default {
QuestEditorStore(ctx.scope, uiStore, areaStore) QuestEditorStore(uiStore, areaStore)
} }
// Rendering // Rendering
var createThreeRenderer: (HTMLCanvasElement) -> DisposableThreeRenderer by default { var createThreeRenderer: (HTMLCanvasElement) -> DisposableThreeRenderer by default {
{ canvas -> { canvas ->
object : DisposableThreeRenderer { object : DisposableThreeRenderer {
override val renderer = NoopRenderer(canvas) override val renderer = NopRenderer(canvas).unsafeCast<WebGLRenderer>()
override fun dispose() {} override fun dispose() {}
} }
} }

View File

@ -9,6 +9,6 @@ class ViewerTests : WebTestSuite() {
val viewer = disposer.add( val viewer = disposer.add(
Viewer(components.createThreeRenderer) Viewer(components.createThreeRenderer)
) )
disposer.add(viewer.initialize(scope)) disposer.add(viewer.initialize())
} }
} }

View File

@ -10,17 +10,16 @@ import org.w3c.dom.pointerevents.PointerEvent
import world.phantasmal.core.disposable.Disposable import world.phantasmal.core.disposable.Disposable
import world.phantasmal.core.disposable.disposable import world.phantasmal.core.disposable.disposable
fun <E : Event> disposableListener( fun <E : Event> EventTarget.disposableListener(
target: EventTarget,
type: String, type: String,
listener: (E) -> Unit, listener: (E) -> Unit,
options: AddEventListenerOptions? = null, options: AddEventListenerOptions? = null,
): Disposable { ): Disposable {
@Suppress("UNCHECKED_CAST") @Suppress("UNCHECKED_CAST")
target.addEventListener(type, listener as (Event) -> Unit, options) addEventListener(type, listener as (Event) -> Unit, options)
return disposable { return disposable {
target.removeEventListener(type, listener) removeEventListener(type, listener)
} }
} }
@ -34,13 +33,13 @@ fun Element.disposablePointerDrag(
var windowMoveListener: Disposable? = null var windowMoveListener: Disposable? = null
var windowUpListener: Disposable? = null var windowUpListener: Disposable? = null
val downListener = disposableListener<PointerEvent>(this, "pointerdown", { downEvent -> val downListener = disposableListener<PointerEvent>("pointerdown", { downEvent ->
if (onPointerDown(downEvent)) { if (onPointerDown(downEvent)) {
prevPointerX = downEvent.clientX prevPointerX = downEvent.clientX
prevPointerY = downEvent.clientY prevPointerY = downEvent.clientY
windowMoveListener = windowMoveListener =
disposableListener<PointerEvent>(window, "pointermove", { moveEvent -> window.disposableListener<PointerEvent>("pointermove", { moveEvent ->
val movedX = moveEvent.clientX - prevPointerX val movedX = moveEvent.clientX - prevPointerX
val movedY = moveEvent.clientY - prevPointerY val movedY = moveEvent.clientY - prevPointerY
prevPointerX = moveEvent.clientX prevPointerX = moveEvent.clientX
@ -53,7 +52,7 @@ fun Element.disposablePointerDrag(
}) })
windowUpListener = windowUpListener =
disposableListener<PointerEvent>(window, "pointerup", { upEvent -> window.disposableListener<PointerEvent>("pointerup", { upEvent ->
onPointerUp(upEvent) onPointerUp(upEvent)
windowMoveListener?.dispose() windowMoveListener?.dispose()
windowUpListener?.dispose() windowUpListener?.dispose()

View File

@ -1,8 +1,15 @@
package world.phantasmal.webui.stores package world.phantasmal.webui.stores
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.cancel
import world.phantasmal.webui.DisposableContainer import world.phantasmal.webui.DisposableContainer
abstract class Store(protected val scope: CoroutineScope) : abstract class Store : DisposableContainer() {
DisposableContainer(), protected val scope: CoroutineScope = CoroutineScope(Dispatchers.Default)
CoroutineScope by scope
override fun internalDispose() {
scope.cancel("Store disposed.")
super.internalDispose()
}
}

View File

@ -1,6 +1,5 @@
package world.phantasmal.webui.widgets package world.phantasmal.webui.widgets
import kotlinx.coroutines.CoroutineScope
import org.w3c.dom.Node import org.w3c.dom.Node
import org.w3c.dom.events.KeyboardEvent import org.w3c.dom.events.KeyboardEvent
import org.w3c.dom.events.MouseEvent import org.w3c.dom.events.MouseEvent
@ -13,7 +12,6 @@ import world.phantasmal.webui.dom.icon
import world.phantasmal.webui.dom.span import world.phantasmal.webui.dom.span
open class Button( open class Button(
scope: CoroutineScope,
visible: Val<Boolean> = trueVal(), visible: Val<Boolean> = trueVal(),
enabled: Val<Boolean> = trueVal(), enabled: Val<Boolean> = trueVal(),
tooltip: Val<String?> = nullVal(), tooltip: Val<String?> = nullVal(),
@ -27,7 +25,7 @@ open class Button(
private val onKeyDown: ((KeyboardEvent) -> Unit)? = null, private val onKeyDown: ((KeyboardEvent) -> Unit)? = null,
private val onKeyUp: ((KeyboardEvent) -> Unit)? = null, private val onKeyUp: ((KeyboardEvent) -> Unit)? = null,
private val onKeyPress: ((KeyboardEvent) -> Unit)? = null, private val onKeyPress: ((KeyboardEvent) -> Unit)? = null,
) : Control(scope, visible, enabled, tooltip) { ) : Control(visible, enabled, tooltip) {
override fun Node.createElement() = override fun Node.createElement() =
button { button {
className = "pw-button" className = "pw-button"

View File

@ -1,6 +1,5 @@
package world.phantasmal.webui.widgets package world.phantasmal.webui.widgets
import kotlinx.coroutines.CoroutineScope
import world.phantasmal.observable.value.Val import world.phantasmal.observable.value.Val
import world.phantasmal.observable.value.nullVal import world.phantasmal.observable.value.nullVal
import world.phantasmal.observable.value.trueVal 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. * etc. Controls are typically leaf nodes and thus typically don't have children.
*/ */
abstract class Control( abstract class Control(
scope: CoroutineScope,
visible: Val<Boolean> = trueVal(), visible: Val<Boolean> = trueVal(),
enabled: Val<Boolean> = trueVal(), enabled: Val<Boolean> = trueVal(),
tooltip: Val<String?> = nullVal(), tooltip: Val<String?> = nullVal(),
) : Widget(scope, visible, enabled, tooltip) ) : Widget(visible, enabled, tooltip)

View File

@ -1,7 +1,6 @@
package world.phantasmal.webui.widgets package world.phantasmal.webui.widgets
import kotlinx.browser.window import kotlinx.browser.window
import kotlinx.coroutines.CoroutineScope
import org.w3c.dom.HTMLElement import org.w3c.dom.HTMLElement
import org.w3c.dom.Node import org.w3c.dom.Node
import org.w3c.dom.events.Event import org.w3c.dom.events.Event
@ -17,14 +16,13 @@ import world.phantasmal.webui.dom.h1
import world.phantasmal.webui.dom.section import world.phantasmal.webui.dom.section
open class Dialog( open class Dialog(
scope: CoroutineScope,
visible: Val<Boolean> = trueVal(), visible: Val<Boolean> = trueVal(),
enabled: Val<Boolean> = trueVal(), enabled: Val<Boolean> = trueVal(),
private val title: Val<String>, private val title: Val<String>,
private val description: Val<String>, private val description: Val<String>,
private val content: Val<Node>, private val content: Val<Node>,
protected val onDismiss: () -> Unit = {}, protected val onDismiss: () -> Unit = {},
) : Widget(scope, visible, enabled) { ) : Widget(visible, enabled) {
private var x = 0 private var x = 0
private var y = 0 private var y = 0

View File

@ -1,6 +1,5 @@
package world.phantasmal.webui.widgets package world.phantasmal.webui.widgets
import kotlinx.coroutines.CoroutineScope
import org.w3c.dom.HTMLInputElement import org.w3c.dom.HTMLInputElement
import world.phantasmal.observable.value.Val import world.phantasmal.observable.value.Val
import world.phantasmal.observable.value.nullVal import world.phantasmal.observable.value.nullVal
@ -11,7 +10,6 @@ import kotlin.math.pow
import kotlin.math.round import kotlin.math.round
class DoubleInput( class DoubleInput(
scope: CoroutineScope,
visible: Val<Boolean> = trueVal(), visible: Val<Boolean> = trueVal(),
enabled: Val<Boolean> = trueVal(), enabled: Val<Boolean> = trueVal(),
tooltip: Val<String?> = nullVal(), tooltip: Val<String?> = nullVal(),
@ -22,7 +20,6 @@ class DoubleInput(
onChange: (Double) -> Unit = {}, onChange: (Double) -> Unit = {},
roundTo: Int = 2, roundTo: Int = 2,
) : NumberInput<Double>( ) : NumberInput<Double>(
scope,
visible, visible,
enabled, enabled,
tooltip, tooltip,

View File

@ -1,17 +1,14 @@
package world.phantasmal.webui.widgets package world.phantasmal.webui.widgets
import kotlinx.coroutines.CoroutineScope
import org.w3c.dom.HTMLElement import org.w3c.dom.HTMLElement
import org.w3c.files.File import org.w3c.files.File
import world.phantasmal.observable.value.Val import world.phantasmal.observable.value.Val
import world.phantasmal.observable.value.falseVal
import world.phantasmal.observable.value.nullVal import world.phantasmal.observable.value.nullVal
import world.phantasmal.observable.value.trueVal import world.phantasmal.observable.value.trueVal
import world.phantasmal.webui.dom.Icon import world.phantasmal.webui.dom.Icon
import world.phantasmal.webui.openFiles import world.phantasmal.webui.openFiles
class FileButton( class FileButton(
scope: CoroutineScope,
visible: Val<Boolean> = trueVal(), visible: Val<Boolean> = trueVal(),
enabled: Val<Boolean> = trueVal(), enabled: Val<Boolean> = trueVal(),
tooltip: Val<String?> = nullVal(), tooltip: Val<String?> = nullVal(),
@ -22,7 +19,7 @@ class FileButton(
private val accept: String = "", private val accept: String = "",
private val multiple: Boolean = false, private val multiple: Boolean = false,
private val filesSelected: ((List<File>) -> Unit)? = null, private val filesSelected: ((List<File>) -> Unit)? = null,
) : Button(scope, visible, enabled, tooltip, text, textVal, iconLeft, iconRight) { ) : Button(visible, enabled, tooltip, text, textVal, iconLeft, iconRight) {
override fun interceptElement(element: HTMLElement) { override fun interceptElement(element: HTMLElement) {
element.classList.add("pw-file-button") element.classList.add("pw-file-button")

View File

@ -1,6 +1,5 @@
package world.phantasmal.webui.widgets package world.phantasmal.webui.widgets
import kotlinx.coroutines.CoroutineScope
import org.w3c.dom.HTMLInputElement import org.w3c.dom.HTMLInputElement
import org.w3c.dom.Node import org.w3c.dom.Node
import world.phantasmal.observable.value.Val import world.phantasmal.observable.value.Val
@ -8,7 +7,6 @@ import world.phantasmal.webui.dom.input
import world.phantasmal.webui.dom.span import world.phantasmal.webui.dom.span
abstract class Input<T>( abstract class Input<T>(
scope: CoroutineScope,
visible: Val<Boolean>, visible: Val<Boolean>,
enabled: Val<Boolean>, enabled: Val<Boolean>,
tooltip: Val<String?>, tooltip: Val<String?>,
@ -25,7 +23,6 @@ abstract class Input<T>(
private val max: Int?, private val max: Int?,
private val step: Int?, private val step: Int?,
) : LabelledControl( ) : LabelledControl(
scope,
visible, visible,
enabled, enabled,
tooltip, tooltip,
@ -58,9 +55,12 @@ abstract class Input<T>(
} }
this@Input.maxLength?.let { maxLength = it } this@Input.maxLength?.let { maxLength = it }
this@Input.min?.let { min = it.toString() }
this@Input.max?.let { max = it.toString() } if (inputType == "number") {
this@Input.step?.let { step = it.toString() } this@Input.min?.let { min = it.toString() }
this@Input.max?.let { max = it.toString() }
step = this@Input.step?.toString() ?: "any"
}
} }
} }

View File

@ -1,6 +1,5 @@
package world.phantasmal.webui.widgets package world.phantasmal.webui.widgets
import kotlinx.coroutines.CoroutineScope
import org.w3c.dom.HTMLInputElement import org.w3c.dom.HTMLInputElement
import world.phantasmal.observable.value.Val import world.phantasmal.observable.value.Val
import world.phantasmal.observable.value.nullVal import world.phantasmal.observable.value.nullVal
@ -8,7 +7,6 @@ import world.phantasmal.observable.value.trueVal
import world.phantasmal.observable.value.value import world.phantasmal.observable.value.value
class IntInput( class IntInput(
scope: CoroutineScope,
visible: Val<Boolean> = trueVal(), visible: Val<Boolean> = trueVal(),
enabled: Val<Boolean> = trueVal(), enabled: Val<Boolean> = trueVal(),
tooltip: Val<String?> = nullVal(), tooltip: Val<String?> = nullVal(),
@ -21,7 +19,6 @@ class IntInput(
max: Int? = null, max: Int? = null,
step: Int? = null, step: Int? = null,
) : NumberInput<Int>( ) : NumberInput<Int>(
scope,
visible, visible,
enabled, enabled,
tooltip, tooltip,

View File

@ -1,19 +1,17 @@
package world.phantasmal.webui.widgets package world.phantasmal.webui.widgets
import kotlinx.coroutines.CoroutineScope
import org.w3c.dom.Node import org.w3c.dom.Node
import world.phantasmal.observable.value.Val import world.phantasmal.observable.value.Val
import world.phantasmal.observable.value.trueVal import world.phantasmal.observable.value.trueVal
import world.phantasmal.webui.dom.label import world.phantasmal.webui.dom.label
class Label( class Label(
scope: CoroutineScope,
visible: Val<Boolean> = trueVal(), visible: Val<Boolean> = trueVal(),
enabled: Val<Boolean> = trueVal(), enabled: Val<Boolean> = trueVal(),
private val text: String? = null, private val text: String? = null,
private val textVal: Val<String>? = null, private val textVal: Val<String>? = null,
private val htmlFor: String? = null, private val htmlFor: String? = null,
) : Widget(scope, visible, enabled) { ) : Widget(visible, enabled) {
override fun Node.createElement() = override fun Node.createElement() =
label { label {
className = "pw-label" className = "pw-label"

View File

@ -1,6 +1,5 @@
package world.phantasmal.webui.widgets package world.phantasmal.webui.widgets
import kotlinx.coroutines.CoroutineScope
import world.phantasmal.observable.value.Val import world.phantasmal.observable.value.Val
enum class LabelPosition { enum class LabelPosition {
@ -9,14 +8,13 @@ enum class LabelPosition {
} }
abstract class LabelledControl( abstract class LabelledControl(
scope: CoroutineScope,
visible: Val<Boolean>, visible: Val<Boolean>,
enabled: Val<Boolean>, enabled: Val<Boolean>,
tooltip: Val<String?>, tooltip: Val<String?>,
label: String?, label: String?,
labelVal: Val<String>?, labelVal: Val<String>?,
val preferredLabelPosition: LabelPosition, val preferredLabelPosition: LabelPosition,
) : Control(scope, visible, enabled, tooltip) { ) : Control(visible, enabled, tooltip) {
val label: Label? by lazy { val label: Label? by lazy {
if (label == null && labelVal == null) { if (label == null && labelVal == null) {
null null
@ -28,7 +26,7 @@ abstract class LabelledControl(
element.id = id element.id = id
} }
Label(scope, visible, enabled, label, labelVal, htmlFor = id) Label( visible, enabled, label, labelVal, htmlFor = id)
} }
} }

View File

@ -1,17 +1,15 @@
package world.phantasmal.webui.widgets package world.phantasmal.webui.widgets
import kotlinx.coroutines.CoroutineScope
import org.w3c.dom.Node import org.w3c.dom.Node
import world.phantasmal.observable.value.Val import world.phantasmal.observable.value.Val
import world.phantasmal.observable.value.trueVal import world.phantasmal.observable.value.trueVal
import world.phantasmal.webui.dom.div import world.phantasmal.webui.dom.div
class LazyLoader( class LazyLoader(
scope: CoroutineScope,
visible: Val<Boolean> = trueVal(), visible: Val<Boolean> = trueVal(),
enabled: Val<Boolean> = trueVal(), enabled: Val<Boolean> = trueVal(),
private val createWidget: (CoroutineScope) -> Widget, private val createWidget: () -> Widget,
) : Widget(scope, visible, enabled) { ) : Widget(visible, enabled) {
private var initialized = false private var initialized = false
override fun Node.createElement() = override fun Node.createElement() =
@ -21,7 +19,7 @@ class LazyLoader(
observe(this@LazyLoader.visible) { v -> observe(this@LazyLoader.visible) { v ->
if (v && !initialized) { if (v && !initialized) {
initialized = true initialized = true
addChild(createWidget(scope)) addChild(createWidget())
} }
} }
} }

View File

@ -1,7 +1,6 @@
package world.phantasmal.webui.widgets package world.phantasmal.webui.widgets
import kotlinx.browser.document import kotlinx.browser.document
import kotlinx.coroutines.CoroutineScope
import org.w3c.dom.* import org.w3c.dom.*
import org.w3c.dom.events.Event import org.w3c.dom.events.Event
import org.w3c.dom.events.KeyboardEvent import org.w3c.dom.events.KeyboardEvent
@ -16,7 +15,6 @@ import world.phantasmal.webui.dom.div
import world.phantasmal.webui.obj import world.phantasmal.webui.obj
class Menu<T : Any>( class Menu<T : Any>(
scope: CoroutineScope,
visible: Val<Boolean> = trueVal(), visible: Val<Boolean> = trueVal(),
enabled: Val<Boolean> = trueVal(), enabled: Val<Boolean> = trueVal(),
tooltip: Val<String?> = nullVal(), tooltip: Val<String?> = nullVal(),
@ -26,7 +24,6 @@ class Menu<T : Any>(
private val onSelect: (T) -> Unit = {}, private val onSelect: (T) -> Unit = {},
private val onCancel: () -> Unit = {}, private val onCancel: () -> Unit = {},
) : Widget( ) : Widget(
scope,
visible, visible,
enabled, enabled,
tooltip, tooltip,
@ -61,7 +58,7 @@ class Menu<T : Any>(
observe(this@Menu.visible) { observe(this@Menu.visible) {
if (it) { if (it) {
onDocumentMouseDownListener = onDocumentMouseDownListener =
disposableListener(document, "mousedown", ::onDocumentMouseDown) document.disposableListener("mousedown", ::onDocumentMouseDown)
} else { } else {
onDocumentMouseDownListener?.dispose() onDocumentMouseDownListener?.dispose()
onDocumentMouseDownListener = null onDocumentMouseDownListener = null
@ -77,7 +74,7 @@ class Menu<T : Any>(
} }
} }
disposableListener(document, "keydown", ::onDocumentKeyDown) document.disposableListener("keydown", ::onDocumentKeyDown)
} }
override fun internalDispose() { override fun internalDispose() {

View File

@ -1,10 +1,8 @@
package world.phantasmal.webui.widgets package world.phantasmal.webui.widgets
import kotlinx.coroutines.CoroutineScope
import world.phantasmal.observable.value.Val import world.phantasmal.observable.value.Val
abstract class NumberInput<T : Number>( abstract class NumberInput<T : Number>(
scope: CoroutineScope,
visible: Val<Boolean>, visible: Val<Boolean>,
enabled: Val<Boolean>, enabled: Val<Boolean>,
tooltip: Val<String?>, tooltip: Val<String?>,
@ -17,7 +15,6 @@ abstract class NumberInput<T : Number>(
max: Int?, max: Int?,
step: Int?, step: Int?,
) : Input<T>( ) : Input<T>(
scope,
visible, visible,
enabled, enabled,
tooltip, tooltip,

Some files were not shown because too many files have changed in this diff Show More