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
when (token) {
is LabelToken -> {
is Token.Label -> {
parseLabel(token)
hasLabel = true
}
is SectionToken,
-> {
is Token.Section -> {
parseSection(token)
}
is IntToken -> {
is Token.Int32 -> {
if (section == SegmentType.Data) {
parseBytes(token)
} else {
addUnexpectedTokenError(token)
}
}
is StringToken -> {
is Token.Str -> {
if (section == SegmentType.String) {
parseString(token)
} else {
addUnexpectedTokenError(token)
}
}
is IdentToken -> {
is Token.Ident -> {
if (section === SegmentType.Instructions) {
parseInstruction(token)
} else {
addUnexpectedTokenError(token)
}
}
is InvalidSectionToken -> {
is Token.InvalidSection -> {
addError(token, "Invalid section type.")
}
is InvalidIdentToken -> {
is Token.InvalidIdent -> {
addError(token, "Invalid identifier.")
}
else -> {
@ -234,9 +233,11 @@ private class Assembler(private val assembly: List<String>, private val inlineSt
}
private fun addUnexpectedTokenError(token: Token) {
addError(token,
addError(
token,
"Unexpected token.",
"Unexpected ${token::class.simpleName} at ${token.srcLoc()}.")
"Unexpected ${token::class.simpleName} at ${token.srcLoc()}.",
)
}
private fun addWarning(token: Token, uiMessage: String) {
@ -246,12 +247,12 @@ private class Assembler(private val assembly: List<String>, private val inlineSt
uiMessage,
lineNo = lineNo,
col = token.col,
length = token.len
length = token.len,
)
)
}
private fun parseLabel(token: LabelToken) {
private fun parseLabel(token: Token.Label) {
val label = token.value
if (!labels.add(label)) {
@ -281,7 +282,7 @@ private class Assembler(private val assembly: List<String>, private val inlineSt
}
if (nextToken != null) {
if (nextToken is IdentToken) {
if (nextToken is Token.Ident) {
parseInstruction(nextToken)
} else {
addError(nextToken, "Expected opcode mnemonic.")
@ -300,7 +301,7 @@ private class Assembler(private val assembly: List<String>, private val inlineSt
}
if (nextToken != null) {
if (nextToken is IntToken) {
if (nextToken is Token.Int32) {
parseBytes(nextToken)
} else {
addError(nextToken, "Expected bytes.")
@ -319,7 +320,7 @@ private class Assembler(private val assembly: List<String>, private val inlineSt
}
if (nextToken != null) {
if (nextToken is StringToken) {
if (nextToken is Token.Str) {
parseString(nextToken)
} else {
addError(nextToken, "Expected a string.")
@ -329,11 +330,11 @@ private class Assembler(private val assembly: List<String>, private val inlineSt
}
}
private fun parseSection(token: SectionToken) {
private fun parseSection(token: Token.Section) {
val section = when (token) {
is CodeSectionToken -> SegmentType.Instructions
is DataSectionToken -> SegmentType.Data
is StringSectionToken -> SegmentType.String
is Token.Section.Code -> SegmentType.Instructions
is Token.Section.Data -> SegmentType.Data
is Token.Section.Str -> SegmentType.String
}
if (this.section == section && !firstSectionMarker) {
@ -348,11 +349,11 @@ private class Assembler(private val assembly: List<String>, private val inlineSt
}
}
private fun parseInstruction(identToken: IdentToken) {
private fun parseInstruction(identToken: Token.Ident) {
val opcode = mnemonicToOpcode(identToken.value)
if (opcode == null) {
addError(identToken, "Unknown instruction.")
addError(identToken, "Unknown opcode.")
} else {
val varargs = opcode.params.any {
it.type is ILabelVarType || it.type is RegRefVarType
@ -362,7 +363,7 @@ private class Assembler(private val assembly: List<String>, private val inlineSt
if (!inlineStackArgs && opcode.stack == StackInteraction.Pop) 0
else opcode.params.size
val argCount = tokens.count { it !is ArgSeparatorToken }
val argCount = tokens.count { it !is Token.ArgSeparator }
val lastToken = tokens.lastOrNull()
val errorLength = lastToken?.let { it.col + it.len - identToken.col } ?: 0
@ -375,11 +376,13 @@ private class Assembler(private val assembly: List<String>, private val inlineSt
addError(
identToken.col,
errorLength,
"Expected $paramCount argument ${if (paramCount == 1) "" else "s"}, got $argCount."
"Expected $paramCount argument ${if (paramCount == 1) "" else "s"}, got $argCount.",
)
return
} else if (varargs && argCount < paramCount) {
// TODO: This check assumes we want at least 1 argument for a vararg parameter.
// Is this correct?
addError(
identToken.col,
errorLength,
@ -388,12 +391,13 @@ private class Assembler(private val assembly: List<String>, private val inlineSt
return
} else if (opcode.stack !== StackInteraction.Pop) {
// Inline arguments.
// Arguments should be inlined right after the opcode.
if (!parseArgs(opcode.params, insArgAndTokens, stack = false)) {
return
}
} else {
if (!this.parseArgs(opcode.params, stackArgAndTokens, stack = true)) {
// Arguments should be passed to the opcode via the stack.
if (!parseArgs(opcode.params, stackArgAndTokens, stack = true)) {
return
}
@ -402,7 +406,7 @@ private class Assembler(private val assembly: List<String>, private val inlineSt
val argAndToken = stackArgAndTokens.getOrNull(i) ?: continue
val (arg, argToken) = argAndToken
if (argToken is RegisterToken) {
if (argToken is Token.Register) {
if (param.type is RegTupRefType) {
addInstruction(
OP_ARG_PUSHB,
@ -527,7 +531,7 @@ private class Assembler(private val assembly: List<String>, private val inlineSt
val token = tokens[i]
val param = params[paramI]
if (token is ArgSeparatorToken) {
if (token is Token.ArgSeparator) {
if (shouldBeArg) {
addError(token, "Expected an argument.")
} else if (
@ -551,7 +555,7 @@ private class Assembler(private val assembly: List<String>, private val inlineSt
var match: Boolean
when (token) {
is IntToken -> {
is Token.Int32 -> {
when (param.type) {
is ByteType -> {
match = true
@ -581,7 +585,7 @@ private class Assembler(private val assembly: List<String>, private val inlineSt
}
}
is FloatToken -> {
is Token.Float32 -> {
match = param.type == FloatType
if (match) {
@ -589,7 +593,7 @@ private class Assembler(private val assembly: List<String>, private val inlineSt
}
}
is RegisterToken -> {
is Token.Register -> {
match = stack ||
param.type is RegRefType ||
param.type is RegRefVarType ||
@ -598,7 +602,7 @@ private class Assembler(private val assembly: List<String>, private val inlineSt
parseRegister(token, argAndTokens)
}
is StringToken -> {
is Token.Str -> {
match = param.type is StringType
if (match) {
@ -649,7 +653,11 @@ private class Assembler(private val assembly: List<String>, private val inlineSt
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 bitSize = 8 * size
// 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
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>()
var token: Token = firstToken
var i = 0
while (token is IntToken) {
while (token is Token.Int32) {
if (token.value < 0) {
addError(token, "Unsigned 8-bit integer can't be less than 0.")
} else if (token.value > 255) {
@ -708,7 +716,7 @@ private class Assembler(private val assembly: List<String>, private val inlineSt
addBytes(bytes.toByteArray())
}
private fun parseString(token: StringToken) {
private fun parseString(token: Token.Str) {
tokens.removeFirstOrNull()?.let { 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 IDENT_REGEX = Regex("""^[a-z][a-z0-9_=<>!]*$""")
sealed class Token(
val col: Int,
val len: Int,
)
sealed class Token {
abstract val col: Int
abstract val len: Int
class IntToken(
col: Int,
len: Int,
class Int32(
override val col: Int,
override val len: Int,
val value: Int,
) : Token(col, len)
) : Token()
class FloatToken(
col: Int,
len: Int,
class Float32(
override val col: Int,
override val len: Int,
val value: Float,
) : Token(col, len)
) : Token()
class InvalidNumberToken(
col: Int,
len: Int,
) : Token(col, len)
class InvalidNumber(
override val col: Int,
override val len: Int,
) : Token()
class RegisterToken(
col: Int,
len: Int,
class Register(
override val col: Int,
override val len: Int,
val value: Int,
) : Token(col, len)
) : Token()
class LabelToken(
col: Int,
len: Int,
class Label(
override val col: Int,
override val len: 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(
col: Int,
len: Int,
) : SectionToken(col, len)
class Data(
override val col: Int,
override val len: Int,
) : Section()
class DataSectionToken(
col: Int,
len: Int,
) : SectionToken(col, len)
class Str(
override val col: Int,
override val len: Int,
) : Section()
}
class StringSectionToken(
col: Int,
len: Int,
) : SectionToken(col, len)
class InvalidSection(
override val col: Int,
override val len: Int,
) : Token()
class InvalidSectionToken(
col: Int,
len: Int,
) : Token(col, len)
class StringToken(
col: Int,
len: Int,
class Str(
override val col: Int,
override val len: Int,
val value: String,
) : Token(col, len)
) : Token()
class UnterminatedStringToken(
col: Int,
len: Int,
class UnterminatedString(
override val col: Int,
override val len: Int,
val value: String,
) : Token(col, len)
) : Token()
class IdentToken(
col: Int,
len: Int,
class Ident(
override val col: Int,
override val len: Int,
val value: String,
) : Token(col, len)
) : Token()
class InvalidIdentToken(
col: Int,
len: Int,
) : Token(col, len)
class InvalidIdent(
override val col: Int,
override val len: Int,
) : Token()
class ArgSeparatorToken(
col: Int,
len: Int,
) : Token(col, len)
class ArgSeparator(
override val col: Int,
override val len: Int,
) : Token()
}
fun tokenizeLine(line: String): MutableList<Token> =
LineTokenizer(line).tokenize()
@ -125,7 +125,7 @@ private class LineTokenizer(private var line: String) {
} else if (char == '-' || char.isDigit()) {
token = tokenizeNumberOrLabel()
} else if (char == ',') {
token = ArgSeparatorToken(col, 1)
token = Token.ArgSeparator(col, 1)
skip()
} else if (char == '.') {
token = tokenizeSection()
@ -206,13 +206,13 @@ private class LineTokenizer(private var line: String) {
}
if (value == null) {
return InvalidNumberToken(col, markedLen())
return Token.InvalidNumber(col, markedLen())
}
return if (isLabel) {
LabelToken(col, markedLen(), value)
Token.Label(col, markedLen(), value)
} else {
IntToken(col, markedLen(), value)
Token.Int32(col, markedLen(), value)
}
}
@ -222,11 +222,11 @@ private class LineTokenizer(private var line: String) {
if (HEX_INT_REGEX.matches(hexStr)) {
hexStr.toIntOrNull(16)?.let { value ->
return IntToken(col, markedLen(), value)
return Token.Int32(col, markedLen(), value)
}
}
return InvalidNumberToken(col, markedLen())
return Token.InvalidNumber(col, markedLen())
}
private fun tokenizeFloat(col: Int): Token {
@ -235,11 +235,11 @@ private class LineTokenizer(private var line: String) {
if (FLOAT_REGEX.matches(floatStr)) {
floatStr.toFloatOrNull()?.let { value ->
return FloatToken(col, markedLen(), value)
return Token.Float32(col, markedLen(), value)
}
}
return InvalidNumberToken(col, markedLen())
return Token.InvalidNumber(col, markedLen())
}
private fun tokenizeRegisterOrIdent(): Token {
@ -262,7 +262,7 @@ private class LineTokenizer(private var line: String) {
return if (isRegister) {
val value = slice().toInt()
RegisterToken(col, markedLen() + 1, value)
Token.Register(col, markedLen() + 1, value)
} else {
back()
tokenizeIdent()
@ -282,10 +282,10 @@ private class LineTokenizer(private var line: String) {
}
return when (slice()) {
".code" -> CodeSectionToken(col, 5)
".data" -> DataSectionToken(col, 5)
".string" -> StringSectionToken(col, 7)
else -> InvalidSectionToken(col, markedLen())
".code" -> Token.Section.Code(col, 5)
".data" -> Token.Section.Data(col, 5)
".string" -> Token.Section.Str(col, 7)
else -> Token.InvalidSection(col, markedLen())
}
}
@ -321,9 +321,9 @@ private class LineTokenizer(private var line: String) {
return if (terminated) {
next()
StringToken(col, markedLen() + 2, value)
Token.Str(col, markedLen() + 2, value)
} else {
UnterminatedStringToken(col, markedLen() + 1, value)
Token.UnterminatedString(col, markedLen() + 1, value)
}
}
@ -351,9 +351,9 @@ private class LineTokenizer(private var line: String) {
val value = slice()
return if (IDENT_REGEX.matches(value)) {
IdentToken(col, markedLen(), value)
Token.Ident(col, markedLen(), value)
} else {
InvalidIdentToken(col, markedLen())
Token.InvalidIdent(col, markedLen())
}
}
}

View File

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

View File

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

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
interface EntityType {
val name: String
/**
* Unique name. E.g. an episode II Delsaber would have (Ep. II) appended to its name.
*/
val uniqueName: String
/**
* Name used in the game.
* Might conflict with other NPC names (e.g. Delsaber from ep. I and ep. II).

View File

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

View File

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

View File

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

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)
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 remove(element: E): Boolean
fun removeAt(index: Int): E
fun replaceAll(elements: Iterable<E>)

View File

@ -45,6 +45,17 @@ class SimpleListVal<E>(
finalizeUpdate(ListValChangeEvent.Change(index, emptyList(), listOf(element)))
}
override fun remove(element: E): Boolean {
val index = elements.indexOf(element)
return if (index != -1) {
removeAt(index)
true
} else {
false
}
}
override fun removeAt(index: Int): E {
val removed = elements.removeAt(index)
finalizeUpdate(ListValChangeEvent.Change(index, listOf(removed), emptyList()))

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
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import world.phantasmal.core.disposable.Disposer
open class TestContext(val disposer: Disposer) {
val scope: CoroutineScope = object : CoroutineScope {
override val coroutineContext = Job()
}
}
open class TestContext(val disposer: Disposer)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

@ -132,4 +132,26 @@ class QuestModel(
_longDescription.value = longDescription
return this
}
fun addEntity(entity: QuestEntityModel<*, *>) {
when (entity) {
is QuestNpcModel -> addNpc(entity)
is QuestObjectModel -> addObject(entity)
}
}
fun addNpc(npc: QuestNpcModel) {
_npcs.add(npc)
}
fun addObject(obj: QuestObjectModel) {
_objects.add(obj)
}
fun removeEntity(entity: QuestEntityModel<*, *>) {
when (entity) {
is QuestNpcModel -> _npcs.remove(entity)
is QuestObjectModel -> _objects.remove(entity)
}
}
}

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,
mesh,
instanceIndex,
selectedWave
selectedWave,
) { index ->
removeAt(index)
modelChanged(entity)

View File

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

View File

@ -1,6 +1,5 @@
package world.phantasmal.web.questEditor.rendering
import kotlinx.coroutines.CoroutineScope
import world.phantasmal.lib.fileFormats.quest.Episode
import world.phantasmal.observable.value.list.ListVal
import world.phantasmal.observable.value.list.listVal
@ -11,12 +10,11 @@ import world.phantasmal.web.questEditor.models.*
import world.phantasmal.web.questEditor.stores.QuestEditorStore
class QuestEditorMeshManager(
scope: CoroutineScope,
areaAssetLoader: AreaAssetLoader,
entityAssetLoader: EntityAssetLoader,
questEditorStore: QuestEditorStore,
renderContext: QuestRenderContext,
) : QuestMeshManager(scope, areaAssetLoader, entityAssetLoader, questEditorStore, renderContext) {
) : QuestMeshManager(areaAssetLoader, entityAssetLoader, questEditorStore, renderContext) {
init {
addDisposables(
map(

View File

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

View File

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

View File

@ -1,37 +1,82 @@
package world.phantasmal.web.questEditor.rendering.input
import world.phantasmal.lib.fileFormats.quest.EntityType
import world.phantasmal.web.externals.three.Vector2
import world.phantasmal.web.questEditor.widgets.EntityDragEvent
sealed class Evt
sealed class PointerEvt : Evt() {
abstract val buttons: Int
abstract val shiftKeyDown: Boolean
abstract val movedSinceLastPointerDown: Boolean
/**
* Pointer position in normalized device space.
*/
abstract val pointerDevicePosition: Vector2
abstract val movedSinceLastPointerDown: Boolean
}
class PointerDownEvt(
override val buttons: Int,
override val shiftKeyDown: Boolean,
override val movedSinceLastPointerDown: Boolean,
override val pointerDevicePosition: Vector2,
override val movedSinceLastPointerDown: Boolean,
) : PointerEvt()
class PointerUpEvt(
override val buttons: Int,
override val shiftKeyDown: Boolean,
override val movedSinceLastPointerDown: Boolean,
override val pointerDevicePosition: Vector2,
override val movedSinceLastPointerDown: Boolean,
) : PointerEvt()
class PointerMoveEvt(
override val buttons: Int,
override val shiftKeyDown: Boolean,
override val movedSinceLastPointerDown: Boolean,
override val pointerDevicePosition: Vector2,
override val movedSinceLastPointerDown: Boolean,
) : PointerEvt()
sealed class EntityDragEvt(
private val event: EntityDragEvent,
/**
* Pointer position in normalized device space.
*/
val pointerDevicePosition: Vector2,
) : Evt() {
val entityType: EntityType = event.entityType
val shiftKeyDown: Boolean = event.shiftKeyDown
fun allowDrop() {
event.allowDrop()
}
fun showDragElement() {
event.showDragElement()
}
fun hideDragElement() {
event.hideDragElement()
}
}
class EntityDragEnterEvt(
event: EntityDragEvent,
pointerDevicePosition: Vector2,
) : EntityDragEvt(event, pointerDevicePosition)
class EntityDragOverEvt(
event: EntityDragEvent,
pointerDevicePosition: Vector2,
) : EntityDragEvt(event, pointerDevicePosition)
class EntityDragLeaveEvt(
event: EntityDragEvent,
pointerDevicePosition: Vector2,
) : EntityDragEvt(event, pointerDevicePosition)
class EntityDropEvt(
event: EntityDragEvent,
pointerDevicePosition: Vector2,
) : EntityDragEvt(event, pointerDevicePosition)

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.StateContext
import world.phantasmal.web.questEditor.stores.QuestEditorStore
import world.phantasmal.web.questEditor.widgets.*
import world.phantasmal.webui.DisposableContainer
import world.phantasmal.webui.dom.disposableListener
@ -42,11 +43,18 @@ class QuestInputManager(
init {
addDisposables(
disposableListener(renderContext.canvas, "pointerdown", ::onPointerDown)
renderContext.canvas.disposableListener("pointerdown", ::onPointerDown)
)
onPointerMoveListener =
disposableListener(renderContext.canvas, "pointermove", ::onPointerMove)
renderContext.canvas.disposableListener("pointermove", ::onPointerMove)
addDisposables(
renderContext.canvas.observeEntityDragEnter(::onEntityDragEnter),
renderContext.canvas.observeEntityDragOver(::onEntityDragOver),
renderContext.canvas.observeEntityDragLeave(::onEntityDragLeave),
renderContext.canvas.observeEntityDrop(::onEntityDrop),
)
// Ensure OrbitalCameraControls attaches its listeners after ours.
cameraInputManager = OrbitalCameraInputManager(
@ -90,16 +98,16 @@ class QuestInputManager(
PointerDownEvt(
e.buttons.toInt(),
shiftKeyDown = e.shiftKey,
movedSinceLastPointerDown,
pointerDevicePosition,
movedSinceLastPointerDown,
)
)
onPointerUpListener = disposableListener(window, "pointerup", ::onPointerUp)
onPointerUpListener = window.disposableListener("pointerup", ::onPointerUp)
// Stop listening to canvas move events and start listening to window move events.
onPointerMoveListener?.dispose()
onPointerMoveListener = disposableListener(window, "pointermove", ::onPointerMove)
onPointerMoveListener = window.disposableListener("pointermove", ::onPointerMove)
}
private fun onPointerUp(e: PointerEvent) {
@ -110,8 +118,8 @@ class QuestInputManager(
PointerUpEvt(
e.buttons.toInt(),
shiftKeyDown = e.shiftKey,
movedSinceLastPointerDown,
pointerDevicePosition,
movedSinceLastPointerDown,
)
)
} finally {
@ -121,7 +129,7 @@ class QuestInputManager(
// Stop listening to window move events and start listening to canvas move events again.
onPointerMoveListener?.dispose()
onPointerMoveListener =
disposableListener(renderContext.canvas, "pointermove", ::onPointerMove)
renderContext.canvas.disposableListener("pointermove", ::onPointerMove)
}
}
@ -132,19 +140,47 @@ class QuestInputManager(
PointerMoveEvt(
e.buttons.toInt(),
shiftKeyDown = e.shiftKey,
movedSinceLastPointerDown,
pointerDevicePosition,
movedSinceLastPointerDown,
)
)
}
private fun onEntityDragEnter(e: EntityDragEvent) {
processPointerEvent(type = null, e.clientX, e.clientY)
state = state.processEvent(EntityDragEnterEvt(e, pointerDevicePosition))
}
private fun onEntityDragOver(e: EntityDragEvent) {
processPointerEvent(type = null, e.clientX, e.clientY)
state = state.processEvent(EntityDragOverEvt(e, pointerDevicePosition))
}
private fun onEntityDragLeave(e: EntityDragEvent) {
processPointerEvent(type = null, e.clientX, e.clientY)
state = state.processEvent(EntityDragLeaveEvt(e, pointerDevicePosition))
}
private fun onEntityDrop(e: EntityDragEvent) {
processPointerEvent(type = null, e.clientX, e.clientY)
state = state.processEvent(EntityDropEvt(e, pointerDevicePosition))
}
private fun processPointerEvent(e: PointerEvent) {
processPointerEvent(e.type, e.clientX, e.clientY)
}
private fun processPointerEvent(type: String?, clientX: Int, clientY: Int) {
val rect = renderContext.canvas.getBoundingClientRect()
pointerPosition.set(e.clientX - rect.left, e.clientY - rect.top)
pointerPosition.set(clientX - rect.left, clientY - rect.top)
pointerDevicePosition.copy(pointerPosition)
renderContext.pointerPosToDeviceCoords(pointerDevicePosition)
when (e.type) {
when (type) {
"pointerdown" -> {
movedSinceLastPointerDown = false
}

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.questEditor.models.QuestEntityModel
import world.phantasmal.web.questEditor.rendering.EntityInstancedMesh
import world.phantasmal.web.questEditor.rendering.input.Evt
import world.phantasmal.web.questEditor.rendering.input.PointerDownEvt
import world.phantasmal.web.questEditor.rendering.input.PointerMoveEvt
import world.phantasmal.web.questEditor.rendering.input.PointerUpEvt
import world.phantasmal.web.questEditor.rendering.input.*
class IdleState(
private val ctx: StateContext,
@ -87,6 +84,17 @@ class IdleState(
shouldCheckHighlight = true
}
}
is EntityDragEnterEvt -> {
val quest = ctx.quest.value
val area = ctx.area.value
if (quest != null && area != null) {
return CreationState(ctx, event, quest, area)
}
}
else -> return this
}
return this

View File

@ -1,14 +1,14 @@
package world.phantasmal.web.questEditor.rendering.input.state
import world.phantasmal.observable.value.Val
import world.phantasmal.web.core.minusAssign
import world.phantasmal.web.core.plusAssign
import world.phantasmal.web.core.rendering.OrbitalCameraInputManager
import world.phantasmal.web.core.toQuaternion
import world.phantasmal.web.externals.three.*
import world.phantasmal.web.questEditor.actions.CreateEntityAction
import world.phantasmal.web.questEditor.actions.RotateEntityAction
import world.phantasmal.web.questEditor.actions.TranslateEntityAction
import world.phantasmal.web.questEditor.models.QuestEntityModel
import world.phantasmal.web.questEditor.models.SectionModel
import world.phantasmal.web.questEditor.models.*
import world.phantasmal.web.questEditor.rendering.QuestRenderContext
import world.phantasmal.web.questEditor.stores.QuestEditorStore
import kotlin.math.PI
@ -19,6 +19,10 @@ class StateContext(
val renderContext: QuestRenderContext,
val cameraInputManager: OrbitalCameraInputManager,
) {
val quest: Val<QuestModel?> = questEditorStore.currentQuest
val area: Val<AreaModel?> = questEditorStore.currentArea
val wave: Val<WaveModel?> = questEditorStore.selectedWave
fun setHighlightedEntity(entity: QuestEntityModel<*, *>?) {
questEditorStore.setHighlightedEntity(entity)
}
@ -27,28 +31,11 @@ class StateContext(
questEditorStore.setSelectedEntity(entity)
}
/**
* @param pointerPosition pointer position in normalized device space
*/
fun translateEntity(
entity: QuestEntityModel<*, *>,
dragAdjust: Vector3,
grabOffset: Vector3,
pointerPosition: Vector2,
vertically: Boolean,
) {
if (vertically) {
translateEntityVertically(entity, dragAdjust, grabOffset, pointerPosition)
} else {
translateEntityHorizontally(entity, dragAdjust, grabOffset, pointerPosition)
}
}
/**
* If the drag-adjusted pointer is over the ground, translate an entity horizontally across the
* ground. Otherwise translate the entity over the horizontal plane that intersects its origin.
*/
private fun translateEntityHorizontally(
fun translateEntityHorizontally(
entity: QuestEntityModel<*, *>,
dragAdjust: Vector3,
grabOffset: Vector3,
@ -80,7 +67,7 @@ class StateContext(
}
}
private fun translateEntityVertically(
fun translateEntityVertically(
entity: QuestEntityModel<*, *>,
dragAdjust: Vector3,
grabOffset: Vector3,
@ -185,6 +172,14 @@ class StateContext(
))
}
fun finalizeEntityCreation(quest: QuestModel, entity: QuestEntityModel<*, *>) {
questEditorStore.pushAction(CreateEntityAction(
::setSelectedEntity,
quest,
entity,
))
}
/**
* @param origin position in normalized device space.
*/

View File

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

View File

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

View File

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

View File

@ -1,8 +1,11 @@
package world.phantasmal.web.questEditor.stores
import kotlinx.coroutines.CoroutineScope
import mu.KotlinLogging
import world.phantasmal.observable.value.*
import world.phantasmal.observable.value.Val
import world.phantasmal.observable.value.and
import world.phantasmal.observable.value.list.emptyListVal
import world.phantasmal.observable.value.mutableVal
import world.phantasmal.observable.value.not
import world.phantasmal.web.core.PwToolType
import world.phantasmal.web.core.actions.Action
import world.phantasmal.web.core.stores.UiStore
@ -15,10 +18,9 @@ import world.phantasmal.webui.stores.Store
private val logger = KotlinLogging.logger {}
class QuestEditorStore(
scope: CoroutineScope,
private val uiStore: UiStore,
private val areaStore: AreaStore,
) : Store(scope) {
) : Store() {
private val _currentQuest = mutableVal<QuestModel?>(null)
private val _currentArea = mutableVal<AreaModel?>(null)
private val _selectedWave = mutableVal<WaveModel?>(null)
@ -52,7 +54,23 @@ class QuestEditorStore(
init {
observe(uiStore.currentTool) { tool ->
if (tool == PwToolType.QuestEditor) {
mainUndo.makeCurrent()
makeMainUndoCurrent()
}
}
observe(currentQuest.flatMap { it?.npcs ?: emptyListVal() }) { npcs ->
val selected = selectedEntity.value
if (selected is QuestNpcModel && selected !in npcs) {
_selectedEntity.value = null
}
}
observe(currentQuest.flatMap { it?.objects ?: emptyListVal() }) { objects ->
val selected = selectedEntity.value
if (selected is QuestObjectModel && selected !in objects) {
_selectedEntity.value = null
}
}
}
@ -143,6 +161,11 @@ class QuestEditorStore(
}
fun executeAction(action: Action) {
pushAction(action)
action.execute()
}
fun pushAction(action: Action) {
require(questEditingEnabled.value) {
val reason = when {
currentQuest.value == null -> " (no current quest)"
@ -151,6 +174,6 @@ class QuestEditorStore(
}
"Quest editing is disabled at the moment$reason."
}
mainUndo.push(action).execute()
mainUndo.push(action)
}
}

View File

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

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

View File

@ -1,17 +1,19 @@
package world.phantasmal.web.questEditor.widgets
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import org.w3c.dom.Node
import world.phantasmal.lib.fileFormats.quest.EntityType
import world.phantasmal.web.questEditor.controllers.EntityListController
import world.phantasmal.web.questEditor.rendering.EntityImageRenderer
import world.phantasmal.webui.dom.div
import world.phantasmal.webui.dom.img
import world.phantasmal.webui.dom.span
import world.phantasmal.webui.widgets.Widget
class EntityListWidget(
scope: CoroutineScope,
private val ctrl: EntityListController,
) : Widget(scope, enabled = ctrl.enabled) {
private val entityImageRenderer: EntityImageRenderer,
) : Widget(enabled = ctrl.enabled) {
override fun Node.createElement() =
div {
className = "pw-quest-editor-entity-list"
@ -20,13 +22,30 @@ class EntityListWidget(
div {
className = "pw-quest-editor-entity-list-inner"
bindChildrenTo(ctrl.entities) { entityType, index ->
bindChildWidgetsTo(ctrl.entities) { entityType, _ ->
EntityListEntityWidget(entityType)
}
}
}
private inner class EntityListEntityWidget(private val entityType: EntityType) : Widget() {
override fun Node.createElement() =
div {
className = "pw-quest-editor-entity-list-entity"
draggable = true
img {
width = 100
height = 100
style.visibility = "hidden"
style.asDynamic().pointerEvents = "none"
scope.launch {
src = entityImageRenderer.renderToImage(entityType)
style.visibility = ""
addDisposable(this@div.entityDndSource(entityType, src))
}
}
span {
@ -34,8 +53,6 @@ class EntityListWidget(
}
}
}
}
}
companion object {
init {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -7,10 +7,11 @@ import world.phantasmal.core.PwResult
import world.phantasmal.core.Severity
import world.phantasmal.core.Success
import world.phantasmal.lib.Endianness
import world.phantasmal.lib.compression.prs.prsDecompress
import world.phantasmal.lib.cursor.Cursor
import world.phantasmal.lib.cursor.cursor
import world.phantasmal.lib.fileFormats.ninja.parseNj
import world.phantasmal.lib.fileFormats.ninja.parseXj
import world.phantasmal.lib.fileFormats.ninja.parseXvm
import world.phantasmal.lib.fileFormats.ninja.*
import world.phantasmal.lib.fileFormats.parseAfs
import world.phantasmal.observable.value.Val
import world.phantasmal.observable.value.mutableVal
import world.phantasmal.web.viewer.store.ViewerStore
@ -38,56 +39,71 @@ class ViewerToolbarController(private val store: ViewerStore) : Controller() {
var success = false
try {
var modelFound = false
var textureFound = false
val kindsFound = mutableSetOf<FileKind>()
for (file in files) {
when (file.extension()?.toLowerCase()) {
"nj" -> {
if (modelFound) continue
val extension = file.extension()?.toLowerCase()
modelFound = true
val njResult = parseNj(readFile(file).cursor(Endianness.Little))
result.addResult(njResult)
val kind = when (extension) {
"nj", "xj" -> FileKind.Model
"afs", "xvm" -> FileKind.Texture
else -> {
result.addProblem(
Severity.Error,
"""File "${file.name}" has an unsupported file type.""",
)
continue
}
}
if (kind in kindsFound) continue
val cursor = readFile(file).cursor(Endianness.Little)
var fileResult: PwResult<*>? = null
when (extension) {
"nj" -> {
val njResult = parseNj(cursor)
fileResult = njResult
if (njResult is Success) {
store.setCurrentNinjaObject(njResult.value.firstOrNull())
success = true
}
}
"xj" -> {
if (modelFound) continue
modelFound = true
val xjResult = parseXj(readFile(file).cursor(Endianness.Little))
result.addResult(xjResult)
val xjResult = parseXj(cursor)
fileResult = xjResult
if (xjResult is Success) {
store.setCurrentNinjaObject(xjResult.value.firstOrNull())
success = true
}
}
"afs" -> {
val afsResult = parseAfsTextures(cursor)
fileResult = afsResult
if (afsResult is Success) {
store.setCurrentTextures(afsResult.value)
}
}
"xvm" -> {
if (textureFound) continue
textureFound = true
val xvmResult = parseXvm(readFile(file).cursor(Endianness.Little))
result.addResult(xvmResult)
val xvmResult = parseXvm(cursor)
fileResult = xvmResult
if (xvmResult is Success) {
store.setCurrentTextures(xvmResult.value.textures)
success = true
}
}
}
else -> {
result.addProblem(
Severity.Error,
"""File "${file.name}" has an unsupported file type."""
)
}
fileResult?.let(result::addResult)
if (fileResult is Success<*>) {
success = true
kindsFound.add(kind)
}
}
} catch (e: Exception) {
@ -105,4 +121,47 @@ class ViewerToolbarController(private val store: ViewerStore) : Controller() {
_result.value = result
_resultDialogVisible.value = result != null && result.problems.isNotEmpty()
}
private fun parseAfsTextures(cursor: Cursor): PwResult<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
import mu.KotlinLogging
import org.w3c.dom.HTMLCanvasElement
import world.phantasmal.lib.fileFormats.ninja.XvrTexture
import world.phantasmal.web.core.rendering.*
@ -12,6 +13,8 @@ import kotlin.math.ceil
import kotlin.math.max
import kotlin.math.sqrt
private val logger = KotlinLogging.logger {}
class TextureRenderer(
store: ViewerStore,
createThreeRenderer: (HTMLCanvasElement) -> DisposableThreeRenderer,
@ -36,7 +39,8 @@ class TextureRenderer(
context.canvas,
context.camera,
Vector3(0.0, 0.0, 5.0),
screenSpacePanning = true
screenSpacePanning = true,
enableRotate = false,
))
init {
@ -71,11 +75,23 @@ class TextureRenderer(
var cell = 0
meshes = textures.map { xvr ->
val texture =
try {
xvrTextureToThree(xvr, filter = NearestFilter)
} catch (e: Exception) {
logger.error(e) { "Couldn't convert XVR texture." }
null
}
val quad = Mesh(
createQuad(x, y, xvr.width, xvr.height),
MeshBasicMaterial(obj {
map = xvrTextureToThree(xvr, filter = NearestFilter)
if (texture == null) {
color = Color(0xFF00FF)
} else {
map = texture
transparent = true
}
})
)
context.scene.add(quad)
@ -96,7 +112,7 @@ class TextureRenderer(
width.toDouble(),
height.toDouble(),
widthSegments = 1.0,
heightSegments = 1.0
heightSegments = 1.0,
)
quad.faceVertexUvs = arrayOf(
arrayOf(

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

@ -9,6 +9,6 @@ class ViewerTests : WebTestSuite() {
val viewer = disposer.add(
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
fun <E : Event> disposableListener(
target: EventTarget,
fun <E : Event> EventTarget.disposableListener(
type: String,
listener: (E) -> Unit,
options: AddEventListenerOptions? = null,
): Disposable {
@Suppress("UNCHECKED_CAST")
target.addEventListener(type, listener as (Event) -> Unit, options)
addEventListener(type, listener as (Event) -> Unit, options)
return disposable {
target.removeEventListener(type, listener)
removeEventListener(type, listener)
}
}
@ -34,13 +33,13 @@ fun Element.disposablePointerDrag(
var windowMoveListener: Disposable? = null
var windowUpListener: Disposable? = null
val downListener = disposableListener<PointerEvent>(this, "pointerdown", { downEvent ->
val downListener = disposableListener<PointerEvent>("pointerdown", { downEvent ->
if (onPointerDown(downEvent)) {
prevPointerX = downEvent.clientX
prevPointerY = downEvent.clientY
windowMoveListener =
disposableListener<PointerEvent>(window, "pointermove", { moveEvent ->
window.disposableListener<PointerEvent>("pointermove", { moveEvent ->
val movedX = moveEvent.clientX - prevPointerX
val movedY = moveEvent.clientY - prevPointerY
prevPointerX = moveEvent.clientX
@ -53,7 +52,7 @@ fun Element.disposablePointerDrag(
})
windowUpListener =
disposableListener<PointerEvent>(window, "pointerup", { upEvent ->
window.disposableListener<PointerEvent>("pointerup", { upEvent ->
onPointerUp(upEvent)
windowMoveListener?.dispose()
windowUpListener?.dispose()

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,17 +1,14 @@
package world.phantasmal.webui.widgets
import kotlinx.coroutines.CoroutineScope
import org.w3c.dom.HTMLElement
import org.w3c.files.File
import world.phantasmal.observable.value.Val
import world.phantasmal.observable.value.falseVal
import world.phantasmal.observable.value.nullVal
import world.phantasmal.observable.value.trueVal
import world.phantasmal.webui.dom.Icon
import world.phantasmal.webui.openFiles
class FileButton(
scope: CoroutineScope,
visible: Val<Boolean> = trueVal(),
enabled: Val<Boolean> = trueVal(),
tooltip: Val<String?> = nullVal(),
@ -22,7 +19,7 @@ class FileButton(
private val accept: String = "",
private val multiple: Boolean = false,
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) {
element.classList.add("pw-file-button")

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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