Ported various features.

This commit is contained in:
Daan Vanden Bosch 2020-12-05 21:48:26 +01:00
parent 515cba5555
commit dc0615e1d2
54 changed files with 899 additions and 196 deletions

View File

@ -2,6 +2,7 @@ plugins {
kotlin("multiplatform") kotlin("multiplatform")
} }
val coroutinesVersion: String by project.ext
val kotlinLoggingVersion: String by project.extra val kotlinLoggingVersion: String by project.extra
kotlin { kotlin {
@ -14,6 +15,7 @@ kotlin {
sourceSets { sourceSets {
commonMain { commonMain {
dependencies { dependencies {
api("org.jetbrains.kotlinx:kotlinx-coroutines-core:$coroutinesVersion")
api("io.github.microutils:kotlin-logging:$kotlinLoggingVersion") api("io.github.microutils:kotlin-logging:$kotlinLoggingVersion")
} }
} }

View File

@ -0,0 +1,17 @@
package world.phantasmal.core.disposable
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlin.coroutines.CoroutineContext
import kotlin.reflect.KClass
class DisposableSupervisedScope(
private val kClass: KClass<*>,
context: CoroutineContext,
) : TrackedDisposable(), CoroutineScope by CoroutineScope(SupervisorJob() + context) {
override fun internalDispose() {
cancel("${kClass.simpleName} disposed.")
super.internalDispose()
}
}

View File

@ -103,7 +103,7 @@ val generateOpcodes = tasks.register("generateOpcodes") {
fun opcodeToCode(writer: PrintWriter, opcode: Map<String, Any>) { fun opcodeToCode(writer: PrintWriter, opcode: Map<String, Any>) {
val code = (opcode["code"] as String).drop(2).toInt(16) val code = (opcode["code"] as String).drop(2).toInt(16)
val codeStr = code.toString(16).toUpperCase().padStart(2, '0') val codeStr = code.toString(16).toUpperCase().padStart(2, '0')
val mnemonic = opcode["mnemonic"] as String? ?: "unknown_$codeStr" val mnemonic = opcode["mnemonic"] as String? ?: "unknown_${codeStr.toLowerCase()}"
val description = opcode["description"] as String? val description = opcode["description"] as String?
val stack = opcode["stack"] as String? val stack = opcode["stack"] as String?

View File

@ -0,0 +1,3 @@
package world.phantasmal.observable
fun <T> emitter(): Emitter<T> = SimpleEmitter()

View File

@ -42,11 +42,11 @@ dependencies {
implementation("org.jetbrains.kotlin:kotlin-serialization:$serializationVersion") implementation("org.jetbrains.kotlin:kotlin-serialization:$serializationVersion")
implementation("org.jetbrains.kotlinx:kotlinx-datetime:0.1.1") implementation("org.jetbrains.kotlinx:kotlinx-datetime:0.1.1")
implementation(npm("golden-layout", "^1.5.9")) implementation(npm("golden-layout", "^1.5.9"))
implementation(npm("monaco-editor", "^0.21.2")) implementation(npm("monaco-editor", "0.20.0"))
implementation(npm("three", "^0.122.0")) implementation(npm("three", "^0.122.0"))
implementation(devNpm("file-loader", "^6.0.0")) implementation(devNpm("file-loader", "^6.0.0"))
implementation(devNpm("monaco-editor-webpack-plugin", "^2.0.0")) implementation(devNpm("monaco-editor-webpack-plugin", "1.9.0"))
testImplementation(kotlin("test-js")) testImplementation(kotlin("test-js"))
testImplementation(project(":test-utils")) testImplementation(project(":test-utils"))

View File

@ -2,6 +2,7 @@ package world.phantasmal.web.application.widgets
import org.w3c.dom.Node import org.w3c.dom.Node
import world.phantasmal.observable.value.falseVal import world.phantasmal.observable.value.falseVal
import world.phantasmal.observable.value.list.listVal
import world.phantasmal.observable.value.value import world.phantasmal.observable.value.value
import world.phantasmal.web.application.controllers.NavigationController import world.phantasmal.web.application.controllers.NavigationController
import world.phantasmal.web.core.dom.externalLink import world.phantasmal.web.core.dom.externalLink
@ -30,8 +31,8 @@ class NavigationWidget(private val ctrl: NavigationController) : Widget() {
val serverSelect = Select( val serverSelect = Select(
enabled = falseVal(), enabled = falseVal(),
label = "Server:", label = "Server:",
items = listOf("Ephinea"), items = listVal("Ephinea"),
selected = "Ephinea", selected = value("Ephinea"),
tooltip = value("Only Ephinea is supported at the moment"), tooltip = value("Only Ephinea is supported at the moment"),
) )
addWidget(serverSelect.label!!) addWidget(serverSelect.label!!)

View File

@ -19,9 +19,11 @@ private val NO_SCALE = Vector3(1.0, 1.0, 1.0)
fun ninjaObjectToMesh( fun ninjaObjectToMesh(
ninjaObject: NinjaObject<*>, ninjaObject: NinjaObject<*>,
textures: List<XvrTexture>, textures: List<XvrTexture>,
boundingVolumes: Boolean = false defaultMaterial: Material? = null,
boundingVolumes: Boolean = false,
): Mesh { ): Mesh {
val builder = MeshBuilder() val builder = MeshBuilder()
defaultMaterial?.let { builder.defaultMaterial(defaultMaterial) }
builder.textures(textures) builder.textures(textures)
NinjaToMeshConverter(builder).convert(ninjaObject) NinjaToMeshConverter(builder).convert(ninjaObject)
return builder.buildMesh(boundingVolumes) return builder.buildMesh(boundingVolumes)
@ -31,9 +33,11 @@ fun ninjaObjectToInstancedMesh(
ninjaObject: NinjaObject<*>, ninjaObject: NinjaObject<*>,
textures: List<XvrTexture>, textures: List<XvrTexture>,
maxInstances: Int, maxInstances: Int,
defaultMaterial: Material? = null,
boundingVolumes: Boolean = false, boundingVolumes: Boolean = false,
): InstancedMesh { ): InstancedMesh {
val builder = MeshBuilder() val builder = MeshBuilder()
defaultMaterial?.let { builder.defaultMaterial(defaultMaterial) }
builder.textures(textures) builder.textures(textures)
NinjaToMeshConverter(builder).convert(ninjaObject) NinjaToMeshConverter(builder).convert(ninjaObject)
return builder.buildInstancedMesh(maxInstances, boundingVolumes) return builder.buildInstancedMesh(maxInstances, boundingVolumes)

View File

@ -1,7 +1,10 @@
package world.phantasmal.web.core.stores package world.phantasmal.web.core.stores
import kotlinx.browser.window import kotlinx.browser.window
import kotlinx.coroutines.launch
import org.w3c.dom.events.KeyboardEvent import org.w3c.dom.events.KeyboardEvent
import world.phantasmal.core.disposable.Disposable
import world.phantasmal.core.disposable.disposable
import world.phantasmal.observable.value.MutableVal import world.phantasmal.observable.value.MutableVal
import world.phantasmal.observable.value.Val import world.phantasmal.observable.value.Val
import world.phantasmal.observable.value.eq import world.phantasmal.observable.value.eq
@ -30,7 +33,7 @@ class UiStore(private val applicationUrl: ApplicationUrl) : Store() {
* parameter values per [applicationUrl]. * parameter values per [applicationUrl].
*/ */
private val parameters: MutableMap<String, Map<String, String>> = mutableMapOf() private val parameters: MutableMap<String, Map<String, String>> = mutableMapOf()
private val globalKeydownHandlers: MutableMap<String, (e: KeyboardEvent) -> Unit> = private val globalKeyDownHandlers: MutableMap<String, suspend (e: KeyboardEvent) -> Unit> =
mutableMapOf() mutableMapOf()
/** /**
@ -78,7 +81,7 @@ class UiStore(private val applicationUrl: ApplicationUrl) : Store() {
.toMap() .toMap()
addDisposables( addDisposables(
window.disposableListener("keydown", ::dispatchGlobalKeydown), window.disposableListener("keydown", ::dispatchGlobalKeyDown),
) )
observe(applicationUrl.url) { setDataFromUrl(it) } observe(applicationUrl.url) { setDataFromUrl(it) }
@ -101,6 +104,21 @@ class UiStore(private val applicationUrl: ApplicationUrl) : Store() {
} }
} }
fun onGlobalKeyDown(
tool: PwToolType,
binding: String,
handler: suspend (KeyboardEvent) -> Unit,
): Disposable {
val key = handlerKey(tool, binding)
require(key !in globalKeyDownHandlers) {
"""Binding "$binding" already exists for tool $tool."""
}
globalKeyDownHandlers[key] = handler
return disposable { globalKeyDownHandlers.remove(key) }
}
/** /**
* Sets [currentTool], [path], [parameters] and [features]. * Sets [currentTool], [path], [parameters] and [features].
*/ */
@ -169,7 +187,7 @@ class UiStore(private val applicationUrl: ApplicationUrl) : Store() {
} }
} }
private fun dispatchGlobalKeydown(e: KeyboardEvent) { private fun dispatchGlobalKeyDown(e: KeyboardEvent) {
val bindingParts = mutableListOf<String>() val bindingParts = mutableListOf<String>()
if (e.ctrlKey) bindingParts.add("Ctrl") if (e.ctrlKey) bindingParts.add("Ctrl")
@ -179,11 +197,11 @@ class UiStore(private val applicationUrl: ApplicationUrl) : Store() {
val binding = bindingParts.joinToString("-") val binding = bindingParts.joinToString("-")
val handler = globalKeydownHandlers[handlerKey(currentTool.value, binding)] val handler = globalKeyDownHandlers[handlerKey(currentTool.value, binding)]
if (handler != null) { if (handler != null) {
e.preventDefault() e.preventDefault()
handler(e) scope.launch { handler(e) }
} }
} }

View File

@ -0,0 +1,57 @@
package world.phantasmal.web.core.undo
import world.phantasmal.observable.value.*
import world.phantasmal.web.core.actions.Action
/**
* Simply contains a single action. [canUndo] and [canRedo] must be managed manually.
*/
class SimpleUndo(
undoManager: UndoManager,
private val description: String,
undo: () -> Unit,
redo: () -> Unit,
) : Undo {
private val action = object : Action {
override val description: String = this@SimpleUndo.description
override fun execute() {
redo()
}
override fun undo() {
undo()
}
}
override val canUndo: MutableVal<Boolean> = mutableVal(false)
override val canRedo: MutableVal<Boolean> = mutableVal(false)
override val firstUndo: Val<Action?> = canUndo.map { if (it) action else null }
override val firstRedo: Val<Action?> = canRedo.map { if (it) action else null }
init {
undoManager.addUndo(this)
}
override fun undo(): Boolean =
if (canUndo.value) {
action.undo()
true
} else {
false
}
override fun redo(): Boolean =
if (canRedo.value) {
action.execute()
true
} else {
false
}
override fun reset() {
canUndo.value = false
canRedo.value = false
}
}

View File

@ -17,10 +17,6 @@ interface Undo {
*/ */
val firstRedo: Val<Action?> val firstRedo: Val<Action?>
/**
* Ensures this undo is the current undo in its [UndoManager].
*/
fun makeCurrent()
fun undo(): Boolean fun undo(): Boolean
fun redo(): Boolean fun redo(): Boolean
fun reset() fun reset()

View File

@ -7,6 +7,7 @@ import world.phantasmal.observable.value.nullVal
import world.phantasmal.web.core.actions.Action import world.phantasmal.web.core.actions.Action
class UndoManager { class UndoManager {
private val undos = mutableListOf<Undo>()
private val _current = mutableVal<Undo>(NopUndo) private val _current = mutableVal<Undo>(NopUndo)
val current: Val<Undo> = _current val current: Val<Undo> = _current
@ -16,7 +17,13 @@ class UndoManager {
val firstUndo: Val<Action?> = current.flatMap { it.firstUndo } val firstUndo: Val<Action?> = current.flatMap { it.firstUndo }
val firstRedo: Val<Action?> = current.flatMap { it.firstRedo } val firstRedo: Val<Action?> = current.flatMap { it.firstRedo }
fun addUndo(undo: Undo) {
undos.add(undo)
}
fun setCurrent(undo: Undo) { fun setCurrent(undo: Undo) {
require(undo in undos) { "Undo $undo is not managed by this UndoManager." }
_current.value = undo _current.value = undo
} }
@ -26,8 +33,11 @@ class UndoManager {
fun redo(): Boolean = fun redo(): Boolean =
current.value.redo() current.value.redo()
fun makeNopCurrent() { /**
setCurrent(NopUndo) * Resets all managed undos.
*/
fun reset() {
undos.forEach { it.reset() }
} }
private object NopUndo : Undo { private object NopUndo : Undo {
@ -36,10 +46,6 @@ class UndoManager {
override val firstUndo = nullVal() override val firstUndo = nullVal()
override val firstRedo = nullVal() override val firstRedo = nullVal()
override fun makeCurrent() {
// Do nothing.
}
override fun undo(): Boolean = false override fun undo(): Boolean = false
override fun redo(): Boolean = false override fun redo(): Boolean = false

View File

@ -10,7 +10,7 @@ import world.phantasmal.web.core.actions.Action
/** /**
* Full-fledged linear undo/redo implementation. * Full-fledged linear undo/redo implementation.
*/ */
class UndoStack(private val manager: UndoManager) : Undo { class UndoStack(manager: UndoManager) : Undo {
private val stack = mutableListVal<Action>() private val stack = mutableListVal<Action>()
/** /**
@ -20,6 +20,10 @@ class UndoStack(private val manager: UndoManager) : Undo {
private val index = mutableVal(0) private val index = mutableVal(0)
private var undoingOrRedoing = false private var undoingOrRedoing = false
init {
manager.addUndo(this)
}
override val canUndo: Val<Boolean> = index gt 0 override val canUndo: Val<Boolean> = index gt 0
override val canRedo: Val<Boolean> = map(stack, index) { stack, index -> index < stack.size } override val canRedo: Val<Boolean> = map(stack, index) { stack, index -> index < stack.size }
@ -28,10 +32,6 @@ class UndoStack(private val manager: UndoManager) : Undo {
override val firstRedo: Val<Action?> = index.map { stack.value.getOrNull(it) } override val firstRedo: Val<Action?> = index.map { stack.value.getOrNull(it) }
override fun makeCurrent() {
manager.setCurrent(this)
}
fun push(action: Action): Action { fun push(action: Action): Action {
if (!undoingOrRedoing) { if (!undoingOrRedoing) {
stack.splice(index.value, stack.value.size - index.value, action) stack.splice(index.value, stack.value.size - index.value, action)

View File

@ -7,6 +7,7 @@ package world.phantasmal.web.externals.monacoEditor
import org.w3c.dom.HTMLElement import org.w3c.dom.HTMLElement
import org.w3c.dom.Range import org.w3c.dom.Range
import kotlin.js.Promise
external fun create( external fun create(
domElement: HTMLElement, domElement: HTMLElement,
@ -122,7 +123,7 @@ external interface IEditor {
scrollType: ScrollType = definedExternally, scrollType: ScrollType = definedExternally,
) )
fun trigger(source: String?, handlerId: String, payload: Any) fun trigger(source: String?, handlerId: String, payload: dynamic)
fun getModel(): dynamic /* ITextModel? | IDiffEditorModel? */ fun getModel(): dynamic /* ITextModel? | IDiffEditorModel? */
fun setModel(model: ITextModel?) fun setModel(model: ITextModel?)
} }
@ -180,6 +181,26 @@ external interface ICodeEditor : IEditor {
fun getOffsetForColumn(lineNumber: Number, column: Number): Number fun getOffsetForColumn(lineNumber: Number, column: Number): Number
fun render(forceRedraw: Boolean = definedExternally) fun render(forceRedraw: Boolean = definedExternally)
fun applyFontInfo(target: HTMLElement) fun applyFontInfo(target: HTMLElement)
fun getSupportedActions(): Array<IEditorAction>
fun getAction(id: String): IEditorAction
fun addAction(descriptor: IActionDescriptor): IDisposable
}
external interface IActionDescriptor {
var id: String
var label: String
var keybindings: Array<Int>
fun run(editor: ICodeEditor, vararg args: dynamic): dynamic
}
external interface IEditorAction {
val id: String
val label: String
val alias: String
fun isSupported(): Boolean
fun run(): Promise<Unit>
} }
external interface IStandaloneCodeEditor : ICodeEditor { external interface IStandaloneCodeEditor : ICodeEditor {
@ -540,8 +561,8 @@ external interface ITextModel {
var uri: Uri var uri: Uri
var id: String var id: String
fun getOptions(): TextModelResolvedOptions fun getOptions(): TextModelResolvedOptions
fun getVersionId(): Number fun getVersionId(): Int
fun getAlternativeVersionId(): Number fun getAlternativeVersionId(): Int
fun setValue(newValue: String) fun setValue(newValue: String)
fun getValue( fun getValue(
eol: EndOfLinePreference = definedExternally, eol: EndOfLinePreference = definedExternally,

View File

@ -181,3 +181,203 @@ open external class Uri : UriComponents {
fun revive(data: Uri? = definedExternally): Uri? fun revive(data: Uri? = definedExternally): Uri?
} }
} }
external object KeyCode {
/**
* Placed first to cover the 0 value of the enum.
*/
val Unknown: Int /* = 0 */
val Backspace: Int /* = 1 */
val Tab: Int /* = 2 */
val Enter: Int /* = 3 */
val Shift: Int /* = 4 */
val Ctrl: Int /* = 5 */
val Alt: Int /* = 6 */
val PauseBreak: Int /* = 7 */
val CapsLock: Int /* = 8 */
val Escape: Int /* = 9 */
val Space: Int /* = 10 */
val PageUp: Int /* = 11 */
val PageDown: Int /* = 12 */
val End: Int /* = 13 */
val Home: Int /* = 14 */
val LeftArrow: Int /* = 15 */
val UpArrow: Int /* = 16 */
val RightArrow: Int /* = 17 */
val DownArrow: Int /* = 18 */
val Insert: Int /* = 19 */
val Delete: Int /* = 20 */
val KEY_0: Int /* = 21 */
val KEY_1: Int /* = 22 */
val KEY_2: Int /* = 23 */
val KEY_3: Int /* = 24 */
val KEY_4: Int /* = 25 */
val KEY_5: Int /* = 26 */
val KEY_6: Int /* = 27 */
val KEY_7: Int /* = 28 */
val KEY_8: Int /* = 29 */
val KEY_9: Int /* = 30 */
val KEY_A: Int /* = 31 */
val KEY_B: Int /* = 32 */
val KEY_C: Int /* = 33 */
val KEY_D: Int /* = 34 */
val KEY_E: Int /* = 35 */
val KEY_F: Int /* = 36 */
val KEY_G: Int /* = 37 */
val KEY_H: Int /* = 38 */
val KEY_I: Int /* = 39 */
val KEY_J: Int /* = 40 */
val KEY_K: Int /* = 41 */
val KEY_L: Int /* = 42 */
val KEY_M: Int /* = 43 */
val KEY_N: Int /* = 44 */
val KEY_O: Int /* = 45 */
val KEY_P: Int /* = 46 */
val KEY_Q: Int /* = 47 */
val KEY_R: Int /* = 48 */
val KEY_S: Int /* = 49 */
val KEY_T: Int /* = 50 */
val KEY_U: Int /* = 51 */
val KEY_V: Int /* = 52 */
val KEY_W: Int /* = 53 */
val KEY_X: Int /* = 54 */
val KEY_Y: Int /* = 55 */
val KEY_Z: Int /* = 56 */
val Meta: Int /* = 57 */
val ContextMenu: Int /* = 58 */
val F1: Int /* = 59 */
val F2: Int /* = 60 */
val F3: Int /* = 61 */
val F4: Int /* = 62 */
val F5: Int /* = 63 */
val F6: Int /* = 64 */
val F7: Int /* = 65 */
val F8: Int /* = 66 */
val F9: Int /* = 67 */
val F10: Int /* = 68 */
val F11: Int /* = 69 */
val F12: Int /* = 70 */
val F13: Int /* = 71 */
val F14: Int /* = 72 */
val F15: Int /* = 73 */
val F16: Int /* = 74 */
val F17: Int /* = 75 */
val F18: Int /* = 76 */
val F19: Int /* = 77 */
val NumLock: Int /* = 78 */
val ScrollLock: Int /* = 79 */
/**
* Used for miscellaneous characters; it can vary by keyboard.
* For the US standard keyboard, the ';:' key
*/
val US_SEMICOLON: Int /* = 80 */
/**
* For any country/region, the '+' key
* For the US standard keyboard, the '=+' key
*/
val US_EQUAL: Int /* = 81 */
/**
* For any country/region, the ',' key
* For the US standard keyboard, the ',<' key
*/
val US_COMMA: Int /* = 82 */
/**
* For any country/region, the '-' key
* For the US standard keyboard, the '-_' key
*/
val US_MINUS: Int /* = 83 */
/**
* For any country/region, the '.' key
* For the US standard keyboard, the '.>' key
*/
val US_DOT: Int /* = 84 */
/**
* Used for miscellaneous characters; it can vary by keyboard.
* For the US standard keyboard, the '/?' key
*/
val US_SLASH: Int /* = 85 */
/**
* Used for miscellaneous characters; it can vary by keyboard.
* For the US standard keyboard, the '`~' key
*/
val US_BACKTICK: Int /* = 86 */
/**
* Used for miscellaneous characters; it can vary by keyboard.
* For the US standard keyboard, the '[{' key
*/
val US_OPEN_SQUARE_BRACKET: Int /* = 87 */
/**
* Used for miscellaneous characters; it can vary by keyboard.
* For the US standard keyboard, the '\|' key
*/
val US_BACKSLASH: Int /* = 88 */
/**
* Used for miscellaneous characters; it can vary by keyboard.
* For the US standard keyboard, the ']}' key
*/
val US_CLOSE_SQUARE_BRACKET: Int /* = 89 */
/**
* Used for miscellaneous characters; it can vary by keyboard.
* For the US standard keyboard, the ''"' key
*/
val US_QUOTE: Int /* = 90 */
/**
* Used for miscellaneous characters; it can vary by keyboard.
*/
val OEM_8: Int /* = 91 */
/**
* Either the angle bracket key or the backslash key on the RT 102-key keyboard.
*/
val OEM_102: Int /* = 92 */
val NUMPAD_0: Int /* = 93 */
val NUMPAD_1: Int /* = 94 */
val NUMPAD_2: Int /* = 95 */
val NUMPAD_3: Int /* = 96 */
val NUMPAD_4: Int /* = 97 */
val NUMPAD_5: Int /* = 98 */
val NUMPAD_6: Int /* = 99 */
val NUMPAD_7: Int /* = 100 */
val NUMPAD_8: Int /* = 101 */
val NUMPAD_9: Int /* = 102 */
val NUMPAD_MULTIPLY: Int /* = 103 */
val NUMPAD_ADD: Int /* = 104 */
val NUMPAD_SEPARATOR: Int /* = 105 */
val NUMPAD_SUBTRACT: Int /* = 106 */
val NUMPAD_DECIMAL: Int /* = 107 */
val NUMPAD_DIVIDE: Int /* = 108 */
/**
* Cover all key codes when IME is processing input.
*/
val KEY_IN_COMPOSITION: Int /* = 109 */
val ABNT_C1: Int /* = 110 */
val ABNT_C2: Int /* = 111 */
/**
* Placed last to cover the length of the enum.
* Please do not depend on this value!
*/
val MAX_VALUE: Int /* = 112 */
}
external object KeyMod {
val CtrlCmd: Int
val Shift: Int
val Alt: Int
val WinCtrl: Int
fun chord(firstPart: Int, secondPart: Int): Int
}

View File

@ -535,7 +535,7 @@ external class CylinderBufferGeometry(
open external class BufferAttribute { open external class BufferAttribute {
var needsUpdate: Boolean var needsUpdate: Boolean
fun copyAt(index1: Int, attribute: BufferAttribute, index2: Int): BufferAttribute fun copyAt(index1: Int, bufferAttribute: BufferAttribute, index2: Int): BufferAttribute
} }
external class Uint16BufferAttribute( external class Uint16BufferAttribute(

View File

@ -6,6 +6,7 @@ import world.phantasmal.web.core.PwToolType
import world.phantasmal.web.core.loading.AssetLoader import world.phantasmal.web.core.loading.AssetLoader
import world.phantasmal.web.core.rendering.DisposableThreeRenderer import world.phantasmal.web.core.rendering.DisposableThreeRenderer
import world.phantasmal.web.core.stores.UiStore import world.phantasmal.web.core.stores.UiStore
import world.phantasmal.web.core.undo.UndoManager
import world.phantasmal.web.questEditor.controllers.* import world.phantasmal.web.questEditor.controllers.*
import world.phantasmal.web.questEditor.loading.AreaAssetLoader import world.phantasmal.web.questEditor.loading.AreaAssetLoader
import world.phantasmal.web.questEditor.loading.EntityAssetLoader import world.phantasmal.web.questEditor.loading.EntityAssetLoader
@ -14,7 +15,7 @@ import world.phantasmal.web.questEditor.persistence.QuestEditorUiPersister
import world.phantasmal.web.questEditor.rendering.EntityImageRenderer import world.phantasmal.web.questEditor.rendering.EntityImageRenderer
import world.phantasmal.web.questEditor.rendering.QuestRenderer import world.phantasmal.web.questEditor.rendering.QuestRenderer
import world.phantasmal.web.questEditor.stores.AreaStore import world.phantasmal.web.questEditor.stores.AreaStore
import world.phantasmal.web.questEditor.stores.AssemblyEditorStore import world.phantasmal.web.questEditor.stores.AsmStore
import world.phantasmal.web.questEditor.stores.QuestEditorStore import world.phantasmal.web.questEditor.stores.QuestEditorStore
import world.phantasmal.web.questEditor.widgets.* import world.phantasmal.web.questEditor.widgets.*
import world.phantasmal.webui.DisposableContainer import world.phantasmal.webui.DisposableContainer
@ -36,14 +37,22 @@ class QuestEditor(
// Persistence // Persistence
val questEditorUiPersister = QuestEditorUiPersister() val questEditorUiPersister = QuestEditorUiPersister()
// Undo
val undoManager = UndoManager()
// Stores // Stores
val areaStore = addDisposable(AreaStore(areaAssetLoader)) val areaStore = addDisposable(AreaStore(areaAssetLoader))
val questEditorStore = addDisposable(QuestEditorStore(uiStore, areaStore)) val questEditorStore = addDisposable(QuestEditorStore(
val assemblyEditorStore = addDisposable(AssemblyEditorStore(questEditorStore)) uiStore,
areaStore,
undoManager,
))
val asmStore = addDisposable(AsmStore(questEditorStore, undoManager))
// Controllers // Controllers
val questEditorController = addDisposable(QuestEditorController(questEditorUiPersister)) val questEditorController = addDisposable(QuestEditorController(questEditorUiPersister))
val toolbarController = addDisposable(QuestEditorToolbarController( val toolbarController = addDisposable(QuestEditorToolbarController(
uiStore,
questLoader, questLoader,
areaStore, areaStore,
questEditorStore, questEditorStore,
@ -51,7 +60,7 @@ class QuestEditor(
val questInfoController = addDisposable(QuestInfoController(questEditorStore)) val questInfoController = addDisposable(QuestInfoController(questEditorStore))
val npcCountsController = addDisposable(NpcCountsController(questEditorStore)) val npcCountsController = addDisposable(NpcCountsController(questEditorStore))
val entityInfoController = addDisposable(EntityInfoController(questEditorStore)) val entityInfoController = addDisposable(EntityInfoController(questEditorStore))
val assemblyEditorController = addDisposable(AssemblyEditorController(assemblyEditorStore)) val asmController = addDisposable(AsmController(asmStore))
val npcListController = addDisposable(EntityListController(questEditorStore, npcs = true)) val npcListController = addDisposable(EntityListController(questEditorStore, npcs = true))
val objectListController = val objectListController =
addDisposable(EntityListController(questEditorStore, npcs = false)) addDisposable(EntityListController(questEditorStore, npcs = false))
@ -73,7 +82,7 @@ class QuestEditor(
{ NpcCountsWidget(npcCountsController) }, { NpcCountsWidget(npcCountsController) },
{ EntityInfoWidget(entityInfoController) }, { EntityInfoWidget(entityInfoController) },
{ QuestEditorRendererWidget(renderer) }, { QuestEditorRendererWidget(renderer) },
{ AssemblyEditorWidget(assemblyEditorController) }, { AsmWidget(asmController) },
{ EntityListWidget(npcListController, entityImageRenderer) }, { EntityListWidget(npcListController, entityImageRenderer) },
{ EntityListWidget(objectListController, 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 DeleteEntityAction(
private val setSelectedEntity: (QuestEntityModel<*, *>) -> Unit,
private val quest: QuestModel,
private val entity: QuestEntityModel<*, *>,
) :Action{
override val description: String = "Delete ${entity.type.name}"
override fun execute() {
quest.removeEntity(entity)
}
override fun undo() {
quest.addEntity(entity)
setSelectedEntity(entity)
}
}

View File

@ -1,8 +1,8 @@
package world.phantasmal.web.questEditor.assembly package world.phantasmal.web.questEditor.asm
import world.phantasmal.core.disposable.TrackedDisposable import world.phantasmal.core.disposable.TrackedDisposable
class AssemblyAnalyser : TrackedDisposable() { class AsmAnalyser : TrackedDisposable() {
fun setAssembly(assembly: List<String>) { fun setAssembly(assembly: List<String>) {
} }
} }

View File

@ -0,0 +1,38 @@
package world.phantasmal.web.questEditor.controllers
import world.phantasmal.observable.Observable
import world.phantasmal.observable.value.*
import world.phantasmal.web.externals.monacoEditor.ITextModel
import world.phantasmal.web.externals.monacoEditor.createModel
import world.phantasmal.web.questEditor.stores.AsmStore
import world.phantasmal.webui.controllers.Controller
class AsmController(private val store: AsmStore) : Controller() {
val enabled: Val<Boolean> = store.editingEnabled
val readOnly: Val<Boolean> = !enabled or store.textModel.isNull()
val textModel: Val<ITextModel> = store.textModel.orElse { EMPTY_MODEL }
val didUndo: Observable<Unit> = store.didUndo
val didRedo: Observable<Unit> = store.didRedo
val inlineStackArgs: Val<Boolean> = store.inlineStackArgs
val inlineStackArgsEnabled: Val<Boolean> = falseVal() // TODO
// TODO: Notify user when disabled because of issues with the ASM.
val inlineStackArgsTooltip: Val<String> = value(
"Transform arg_push* opcodes to be inline with the opcode the arguments are given to."
)
fun makeUndoCurrent() {
store.makeUndoCurrent()
}
fun setInlineStackArgs(value: Boolean) {
TODO()
}
companion object {
private val EMPTY_MODEL = createModel("", AsmStore.ASM_LANG_ID)
}
}

View File

@ -1,17 +0,0 @@
package world.phantasmal.web.questEditor.controllers
import world.phantasmal.observable.value.*
import world.phantasmal.web.externals.monacoEditor.ITextModel
import world.phantasmal.web.externals.monacoEditor.createModel
import world.phantasmal.web.questEditor.stores.AssemblyEditorStore
import world.phantasmal.webui.controllers.Controller
class AssemblyEditorController(assemblyEditorStore: AssemblyEditorStore) : Controller() {
val textModel: Val<ITextModel> = assemblyEditorStore.textModel.orElse { EMPTY_MODEL }
val enabled: Val<Boolean> = assemblyEditorStore.editingEnabled
val readOnly: Val<Boolean> = enabled.not() or assemblyEditorStore.textModel.isNull()
companion object {
private val EMPTY_MODEL = createModel("", AssemblyEditorStore.ASM_LANG_ID)
}
}

View File

@ -7,13 +7,17 @@ import world.phantasmal.web.questEditor.models.QuestNpcModel
import world.phantasmal.web.questEditor.stores.QuestEditorStore import world.phantasmal.web.questEditor.stores.QuestEditorStore
import world.phantasmal.webui.controllers.Controller import world.phantasmal.webui.controllers.Controller
class NpcCountsController(store: QuestEditorStore) : Controller() { class NpcCountsController(private val store: QuestEditorStore) : Controller() {
val unavailable: Val<Boolean> = store.currentQuest.isNull() val unavailable: Val<Boolean> = store.currentQuest.isNull()
val npcCounts: Val<List<NameWithCount>> = store.currentQuest val npcCounts: Val<List<NameWithCount>> = store.currentQuest
.flatMap { it?.npcs ?: emptyListVal() } .flatMap { it?.npcs ?: emptyListVal() }
.map(::countNpcs) .map(::countNpcs)
fun focused() {
store.makeMainUndoCurrent()
}
private fun countNpcs(npcs: List<QuestNpcModel>): List<NameWithCount> { private fun countNpcs(npcs: List<QuestNpcModel>): List<NameWithCount> {
val npcCounts = mutableMapOf<NpcType, Int>() val npcCounts = mutableMapOf<NpcType, Int>()
var extraCanadines = 0 var extraCanadines = 0

View File

@ -15,24 +15,24 @@ class QuestEditorController(
companion object { companion object {
// These IDs are persisted, don't change them. // These IDs are persisted, don't change them.
const val QUEST_INFO_WIDGET_ID = "info" const val QUEST_INFO_WIDGET_ID = "quest-info"
const val NPC_COUNTS_WIDGET_ID = "npc_counts" const val NPC_COUNTS_WIDGET_ID = "npc-counts"
const val ENTITY_INFO_WIDGET_ID = "entity_info" const val ENTITY_INFO_WIDGET_ID = "entity-info"
const val QUEST_RENDERER_WIDGET_ID = "quest_renderer" const val QUEST_RENDERER_WIDGET_ID = "quest-renderer"
const val ASSEMBLY_EDITOR_WIDGET_ID = "asm_editor" const val ASM_WIDGET_ID = "asm"
const val NPC_LIST_WIDGET_ID = "npc_list_view" const val NPC_LIST_WIDGET_ID = "npc-list"
const val OBJECT_LIST_WIDGET_ID = "object_list_view" const val OBJECT_LIST_WIDGET_ID = "object-list"
const val EVENTS_WIDGET_ID = "events_view" const val EVENTS_WIDGET_ID = "events"
private val ALL_WIDGET_IDS: Set<String> = setOf( private val ALL_WIDGET_IDS: Set<String> = setOf(
"info", QUEST_INFO_WIDGET_ID,
"npc_counts", NPC_COUNTS_WIDGET_ID,
"entity_info", ENTITY_INFO_WIDGET_ID,
"quest_renderer", QUEST_RENDERER_WIDGET_ID,
"asm_editor", ASM_WIDGET_ID,
"npc_list_view", NPC_LIST_WIDGET_ID,
"object_list_view", OBJECT_LIST_WIDGET_ID,
"events_view", EVENTS_WIDGET_ID,
) )
private val DEFAULT_CONFIG = DockedRow( private val DEFAULT_CONFIG = DockedRow(
@ -67,7 +67,7 @@ class QuestEditorController(
), ),
DockedWidget( DockedWidget(
title = "Script", title = "Script",
id = ASSEMBLY_EDITOR_WIDGET_ID, id = ASM_WIDGET_ID,
), ),
) )
), ),

View File

@ -13,6 +13,8 @@ import world.phantasmal.observable.value.Val
import world.phantasmal.observable.value.map import world.phantasmal.observable.value.map
import world.phantasmal.observable.value.mutableVal import world.phantasmal.observable.value.mutableVal
import world.phantasmal.observable.value.value import world.phantasmal.observable.value.value
import world.phantasmal.web.core.PwToolType
import world.phantasmal.web.core.stores.UiStore
import world.phantasmal.web.questEditor.loading.QuestLoader import world.phantasmal.web.questEditor.loading.QuestLoader
import world.phantasmal.web.questEditor.models.AreaModel import world.phantasmal.web.questEditor.models.AreaModel
import world.phantasmal.web.questEditor.stores.AreaStore import world.phantasmal.web.questEditor.stores.AreaStore
@ -20,12 +22,14 @@ import world.phantasmal.web.questEditor.stores.QuestEditorStore
import world.phantasmal.web.questEditor.stores.convertQuestToModel import world.phantasmal.web.questEditor.stores.convertQuestToModel
import world.phantasmal.webui.controllers.Controller import world.phantasmal.webui.controllers.Controller
import world.phantasmal.webui.readFile import world.phantasmal.webui.readFile
import world.phantasmal.webui.selectFiles
private val logger = KotlinLogging.logger {} private val logger = KotlinLogging.logger {}
class AreaAndLabel(val area: AreaModel, val label: String) class AreaAndLabel(val area: AreaModel, val label: String)
class QuestEditorToolbarController( class QuestEditorToolbarController(
uiStore: UiStore,
private val questLoader: QuestLoader, private val questLoader: QuestLoader,
private val areaStore: AreaStore, private val areaStore: AreaStore,
private val questEditorStore: QuestEditorStore, private val questEditorStore: QuestEditorStore,
@ -38,6 +42,8 @@ class QuestEditorToolbarController(
val resultDialogVisible: Val<Boolean> = _resultDialogVisible val resultDialogVisible: Val<Boolean> = _resultDialogVisible
val result: Val<PwResult<*>?> = _result val result: Val<PwResult<*>?> = _result
val openFileAccept = ".bin, .dat, .qst"
// Undo // Undo
val undoTooltip: Val<String> = questEditorStore.firstUndo.map { action -> val undoTooltip: Val<String> = questEditorStore.firstUndo.map { action ->
@ -75,6 +81,26 @@ class QuestEditorToolbarController(
val areaSelectEnabled: Val<Boolean> = questEditorStore.currentQuest.isNotNull() val areaSelectEnabled: Val<Boolean> = questEditorStore.currentQuest.isNotNull()
init {
addDisposables(
uiStore.onGlobalKeyDown(PwToolType.QuestEditor, "Ctrl-O") {
openFiles(selectFiles(accept = openFileAccept, multiple = true))
},
uiStore.onGlobalKeyDown(PwToolType.QuestEditor, "Ctrl-Z") {
undo()
},
uiStore.onGlobalKeyDown(PwToolType.QuestEditor, "Ctrl-Shift-Z") {
redo()
},
uiStore.onGlobalKeyDown(PwToolType.QuestEditor, "Ctrl-Y") {
redo()
},
)
}
suspend fun createNewQuest(episode: Episode) { suspend fun createNewQuest(episode: Episode) {
// TODO: Set filename and version. // TODO: Set filename and version.
questEditorStore.setCurrentQuest( questEditorStore.setCurrentQuest(

View File

@ -14,10 +14,9 @@ import world.phantasmal.lib.fileFormats.quest.ObjectType
import world.phantasmal.web.core.loading.AssetLoader import world.phantasmal.web.core.loading.AssetLoader
import world.phantasmal.web.core.rendering.conversion.ninjaObjectToInstancedMesh import world.phantasmal.web.core.rendering.conversion.ninjaObjectToInstancedMesh
import world.phantasmal.web.core.rendering.disposeObject3DResources import world.phantasmal.web.core.rendering.disposeObject3DResources
import world.phantasmal.web.externals.three.CylinderBufferGeometry import world.phantasmal.web.externals.three.*
import world.phantasmal.web.externals.three.InstancedMesh
import world.phantasmal.web.externals.three.MeshLambertMaterial
import world.phantasmal.webui.DisposableContainer import world.phantasmal.webui.DisposableContainer
import world.phantasmal.webui.obj
private val logger = KotlinLogging.logger {} private val logger = KotlinLogging.logger {}
@ -26,10 +25,11 @@ class EntityAssetLoader(private val assetLoader: AssetLoader) : DisposableContai
LoadingCache<Pair<EntityType, Int?>, InstancedMesh>( LoadingCache<Pair<EntityType, Int?>, InstancedMesh>(
{ (type, model) -> { (type, model) ->
try { try {
loadMesh(type, model) ?: DEFAULT_MESH loadMesh(type, model)
?: if (type is NpcType) DEFAULT_NPC_MESH else DEFAULT_OBJECT_MESH
} catch (e: Exception) { } catch (e: Exception) {
logger.error(e) { "Couldn't load mesh for $type (model: $model)." } logger.error(e) { "Couldn't load mesh for $type (model: $model)." }
DEFAULT_MESH if (type is NpcType) DEFAULT_NPC_MESH else DEFAULT_OBJECT_MESH
} }
}, },
::disposeObject3DResources ::disposeObject3DResources
@ -60,8 +60,12 @@ class EntityAssetLoader(private val assetLoader: AssetLoader) : DisposableContai
ninjaObject, ninjaObject,
textures, textures,
maxInstances = 300, maxInstances = 300,
defaultMaterial = MeshLambertMaterial(obj {
color = if (type is NpcType) DEFAULT_NPC_COLOR else DEFAULT_OBJECT_COLOR
side = DoubleSide
}),
boundingVolumes = true, boundingVolumes = true,
) ).apply { name = type.uniqueName }
} }
private suspend fun loadTextures(type: EntityType, model: Int?): List<XvrTexture> { private suspend fun loadTextures(type: EntityType, model: Int?): List<XvrTexture> {
@ -121,18 +125,25 @@ class EntityAssetLoader(private val assetLoader: AssetLoader) : DisposableContai
} }
companion object { companion object {
private val DEFAULT_MESH = InstancedMesh( private val DEFAULT_NPC_COLOR = Color(0xFF0000)
private val DEFAULT_OBJECT_COLOR = Color(0xFFFF00)
private val DEFAULT_NPC_MESH = createCylinder(DEFAULT_NPC_COLOR)
private val DEFAULT_OBJECT_MESH = createCylinder(DEFAULT_OBJECT_COLOR)
private fun createCylinder(color: Color) =
InstancedMesh(
CylinderBufferGeometry( CylinderBufferGeometry(
radiusTop = 2.5, radiusTop = 2.5,
radiusBottom = 2.5, radiusBottom = 2.5,
height = 18.0, height = 18.0,
radialSegments = 16, radialSegments = 20,
).apply { ).apply {
translate(0.0, 9.0, 0.0) translate(0.0, 9.0, 0.0)
computeBoundingBox() computeBoundingBox()
computeBoundingSphere() computeBoundingSphere()
}, },
MeshLambertMaterial(), MeshLambertMaterial(obj { this.color = color }),
count = 1000, count = 1000,
).apply { ).apply {
// Start with 0 instances. // Start with 0 instances.

View File

@ -8,7 +8,7 @@ class LoadingCache<K, V>(
private val loadValue: suspend (K) -> V, private val loadValue: suspend (K) -> V,
private val disposeValue: (V) -> Unit, private val disposeValue: (V) -> Unit,
) : TrackedDisposable() { ) : TrackedDisposable() {
private val scope: CoroutineScope = CoroutineScope(Dispatchers.Default) private val scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
private val map = mutableMapOf<K, Deferred<V>>() private val map = mutableMapOf<K, Deferred<V>>()
val values: Collection<Deferred<V>> = map.values val values: Collection<Deferred<V>> = map.values

View File

@ -1,5 +1,6 @@
package world.phantasmal.web.questEditor.rendering package world.phantasmal.web.questEditor.rendering
import kotlinx.coroutines.CancellationException
import mu.KotlinLogging import mu.KotlinLogging
import world.phantasmal.lib.fileFormats.quest.Episode import world.phantasmal.lib.fileFormats.quest.Episode
import world.phantasmal.web.questEditor.loading.AreaAssetLoader import world.phantasmal.web.questEditor.loading.AreaAssetLoader
@ -21,6 +22,8 @@ class AreaMeshManager(
try { try {
renderContext.collisionGeometry = renderContext.collisionGeometry =
areaAssetLoader.loadCollisionGeometry(episode, areaVariant) areaAssetLoader.loadCollisionGeometry(episode, areaVariant)
} catch (e: CancellationException) {
// Do nothing.
} catch (e: Exception) { } catch (e: Exception) {
logger.error(e) { logger.error(e) {
"Couldn't load models for area ${areaVariant.area.id}, variant ${areaVariant.id}." "Couldn't load models for area ${areaVariant.area.id}, variant ${areaVariant.id}."

View File

@ -59,13 +59,14 @@ class EntityInstancedMesh(
private fun removeAt(index: Int) { private fun removeAt(index: Int) {
val instance = instances.removeAt(index) val instance = instances.removeAt(index)
instance.mesh.count-- mesh.count--
for (i in index until instance.mesh.count) { for (i in index..instances.lastIndex) {
instance.mesh.instanceMatrix.copyAt(i, instance.mesh.instanceMatrix, i + 1) mesh.instanceMatrix.copyAt(i, mesh.instanceMatrix, i + 1)
instances[i].instanceIndex = i instances[i].instanceIndex = i
} }
mesh.instanceMatrix.needsUpdate = true
instance.dispose() instance.dispose()
} }

View File

@ -2,6 +2,7 @@ package world.phantasmal.web.questEditor.rendering
import kotlinx.coroutines.* import kotlinx.coroutines.*
import mu.KotlinLogging import mu.KotlinLogging
import world.phantasmal.core.disposable.DisposableSupervisedScope
import world.phantasmal.lib.fileFormats.quest.EntityType import world.phantasmal.lib.fileFormats.quest.EntityType
import world.phantasmal.web.externals.three.BoxHelper import world.phantasmal.web.externals.three.BoxHelper
import world.phantasmal.web.externals.three.Color import world.phantasmal.web.externals.three.Color
@ -19,7 +20,7 @@ class EntityMeshManager(
private val renderContext: QuestRenderContext, private val renderContext: QuestRenderContext,
private val entityAssetLoader: EntityAssetLoader, private val entityAssetLoader: EntityAssetLoader,
) : DisposableContainer() { ) : DisposableContainer() {
private val scope: CoroutineScope = CoroutineScope(Dispatchers.Main) private val scope = addDisposable(DisposableSupervisedScope(this::class, Dispatchers.Main))
/** /**
* Contains one [EntityInstancedMesh] per [EntityType] and model. * Contains one [EntityInstancedMesh] per [EntityType] and model.
@ -50,7 +51,7 @@ class EntityMeshManager(
/** /**
* Bounding box around the highlighted entity. * Bounding box around the highlighted entity.
*/ */
private val highlightedBox = BoxHelper(color = Color(0.7, 0.7, 0.7)).apply { private val highlightedBox = BoxHelper(color = Color(.7, .7, .7)).apply {
visible = false visible = false
renderContext.scene.add(this) renderContext.scene.add(this)
} }
@ -58,7 +59,7 @@ class EntityMeshManager(
/** /**
* Bounding box around the selected entity. * Bounding box around the selected entity.
*/ */
private val selectedBox = BoxHelper(color = Color(0.9, 0.9, 0.9)).apply { private val selectedBox = BoxHelper(color = Color(.9, .9, .9)).apply {
visible = false visible = false
renderContext.scene.add(this) renderContext.scene.add(this)
} }
@ -113,7 +114,7 @@ class EntityMeshManager(
} }
fun remove(entity: QuestEntityModel<*, *>) { fun remove(entity: QuestEntityModel<*, *>) {
loadingEntities.remove(entity)?.cancel() loadingEntities.remove(entity)?.cancel("Removed.")
entityMeshCache.getIfPresentNow( entityMeshCache.getIfPresentNow(
TypeAndModel( TypeAndModel(
@ -125,7 +126,7 @@ class EntityMeshManager(
@OptIn(ExperimentalCoroutinesApi::class) @OptIn(ExperimentalCoroutinesApi::class)
fun removeAll() { fun removeAll() {
loadingEntities.values.forEach { it.cancel() } loadingEntities.values.forEach { it.cancel("Removed.") }
loadingEntities.clear() loadingEntities.clear()
for (meshContainerDeferred in entityMeshCache.values) { for (meshContainerDeferred in entityMeshCache.values) {
@ -144,7 +145,7 @@ class EntityMeshManager(
attachBoxHelper( attachBoxHelper(
highlightedBox, highlightedBox,
highlightedEntityInstance, highlightedEntityInstance,
instance instance,
) )
highlightedEntityInstance = instance highlightedEntityInstance = instance
} }

View File

@ -1,9 +1,9 @@
package world.phantasmal.web.questEditor.rendering package world.phantasmal.web.questEditor.rendering
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import world.phantasmal.core.disposable.DisposableSupervisedScope
import world.phantasmal.core.disposable.Disposer import world.phantasmal.core.disposable.Disposer
import world.phantasmal.lib.fileFormats.quest.Episode import world.phantasmal.lib.fileFormats.quest.Episode
import world.phantasmal.observable.value.list.ListVal import world.phantasmal.observable.value.list.ListVal
@ -25,7 +25,7 @@ abstract class QuestMeshManager protected constructor(
questEditorStore: QuestEditorStore, questEditorStore: QuestEditorStore,
renderContext: QuestRenderContext, renderContext: QuestRenderContext,
) : DisposableContainer() { ) : DisposableContainer() {
private val scope: CoroutineScope = CoroutineScope(Dispatchers.Default) private val scope = addDisposable(DisposableSupervisedScope(this::class, Dispatchers.Default))
private val areaDisposer = addDisposable(Disposer()) private val areaDisposer = addDisposable(Disposer())
private val areaMeshManager = AreaMeshManager(renderContext, areaAssetLoader) private val areaMeshManager = AreaMeshManager(renderContext, areaAssetLoader)
private val entityMeshManager = addDisposable( private val entityMeshManager = addDisposable(

View File

@ -6,6 +6,8 @@ import world.phantasmal.web.questEditor.widgets.EntityDragEvent
sealed class Evt sealed class Evt
class KeyboardEvt(val key: String) : Evt()
sealed class PointerEvt : Evt() { sealed class PointerEvt : Evt() {
abstract val buttons: Int abstract val buttons: Int
abstract val shiftKeyDown: Boolean abstract val shiftKeyDown: Boolean
@ -38,6 +40,13 @@ class PointerMoveEvt(
override val movedSinceLastPointerDown: Boolean, override val movedSinceLastPointerDown: Boolean,
) : PointerEvt() ) : PointerEvt()
class PointerOutEvt(
override val buttons: Int,
override val shiftKeyDown: Boolean,
override val pointerDevicePosition: Vector2,
override val movedSinceLastPointerDown: Boolean,
) : PointerEvt()
sealed class EntityDragEvt( sealed class EntityDragEvt(
private val event: EntityDragEvent, private val event: EntityDragEvent,
/** /**

View File

@ -1,6 +1,8 @@
package world.phantasmal.web.questEditor.rendering.input package world.phantasmal.web.questEditor.rendering.input
import kotlinx.browser.window import kotlinx.browser.window
import org.w3c.dom.events.FocusEvent
import org.w3c.dom.events.KeyboardEvent
import org.w3c.dom.pointerevents.PointerEvent import org.w3c.dom.pointerevents.PointerEvent
import world.phantasmal.core.disposable.Disposable import world.phantasmal.core.disposable.Disposable
import world.phantasmal.web.core.rendering.InputManager import world.phantasmal.web.core.rendering.InputManager
@ -17,7 +19,7 @@ import world.phantasmal.webui.DisposableContainer
import world.phantasmal.webui.dom.disposableListener import world.phantasmal.webui.dom.disposableListener
class QuestInputManager( class QuestInputManager(
questEditorStore: QuestEditorStore, private val questEditorStore: QuestEditorStore,
private val renderContext: QuestRenderContext, private val renderContext: QuestRenderContext,
) : DisposableContainer(), InputManager { ) : DisposableContainer(), InputManager {
private val stateContext: StateContext private val stateContext: StateContext
@ -42,14 +44,18 @@ class QuestInputManager(
} }
init { init {
addDisposables(
renderContext.canvas.disposableListener("pointerdown", ::onPointerDown)
)
onPointerMoveListener = onPointerMoveListener =
renderContext.canvas.disposableListener("pointermove", ::onPointerMove) renderContext.canvas.disposableListener("pointermove", ::onPointerMove)
addDisposables( addDisposables(
renderContext.canvas.disposableListener<FocusEvent>(
"focus",
{ onFocus() },
useCapture = true,
),
renderContext.canvas.disposableListener("pointerdown", ::onPointerDown),
renderContext.canvas.disposableListener("pointerout", ::onPointerOut),
renderContext.canvas.disposableListener("keydown", ::onKeyDown),
renderContext.canvas.observeEntityDragEnter(::onEntityDragEnter), renderContext.canvas.observeEntityDragEnter(::onEntityDragEnter),
renderContext.canvas.observeEntityDragOver(::onEntityDragOver), renderContext.canvas.observeEntityDragOver(::onEntityDragOver),
renderContext.canvas.observeEntityDragLeave(::onEntityDragLeave), renderContext.canvas.observeEntityDragLeave(::onEntityDragLeave),
@ -91,6 +97,10 @@ class QuestInputManager(
cameraInputManager.beforeRender() cameraInputManager.beforeRender()
} }
private fun onFocus() {
questEditorStore.makeMainUndoCurrent()
}
private fun onPointerDown(e: PointerEvent) { private fun onPointerDown(e: PointerEvent) {
processPointerEvent(e) processPointerEvent(e)
@ -146,6 +156,23 @@ class QuestInputManager(
) )
} }
private fun onPointerOut(e: PointerEvent) {
processPointerEvent(type = null, e.clientX, e.clientY)
state = state.processEvent(
PointerOutEvt(
e.buttons.toInt(),
shiftKeyDown = e.shiftKey,
pointerDevicePosition,
movedSinceLastPointerDown,
)
)
}
private fun onKeyDown(e: KeyboardEvent) {
state = state.processEvent(KeyboardEvt(e.key))
}
private fun onEntityDragEnter(e: EntityDragEvent) { private fun onEntityDragEnter(e: EntityDragEvent) {
processPointerEvent(type = null, e.clientX, e.clientY) processPointerEvent(type = null, e.clientX, e.clientY)

View File

@ -36,6 +36,7 @@ class CreationState(
else -> error("Unsupported entity type ${event.entityType::class}.") else -> error("Unsupported entity type ${event.entityType::class}.")
} }
private val dragAdjust = Vector3(.0, .0, .0)
private val pointerDevicePosition = Vector2() private val pointerDevicePosition = Vector2()
private var shouldTranslate = false private var shouldTranslate = false
private var shouldTranslateVertically = false private var shouldTranslateVertically = false
@ -83,14 +84,14 @@ class CreationState(
if (shouldTranslateVertically) { if (shouldTranslateVertically) {
ctx.translateEntityVertically( ctx.translateEntityVertically(
entity, entity,
ZERO_VECTOR, dragAdjust,
ZERO_VECTOR, ZERO_VECTOR,
pointerDevicePosition, pointerDevicePosition,
) )
} else { } else {
ctx.translateEntityHorizontally( ctx.translateEntityHorizontally(
entity, entity,
ZERO_VECTOR, dragAdjust,
ZERO_VECTOR, ZERO_VECTOR,
pointerDevicePosition, pointerDevicePosition,
) )

View File

@ -19,6 +19,17 @@ class IdleState(
override fun processEvent(event: Evt): State { override fun processEvent(event: Evt): State {
when (event) { when (event) {
is KeyboardEvt -> {
if (entityManipulationEnabled) {
val quest = ctx.quest.value
val entity = ctx.selectedEntity.value
if (quest != null && entity != null && event.key == "Delete") {
ctx.deleteEntity(quest, entity)
}
}
}
is PointerDownEvt -> { is PointerDownEvt -> {
val pick = pickEntity(event.pointerDevicePosition) val pick = pickEntity(event.pointerDevicePosition)
@ -85,6 +96,11 @@ class IdleState(
} }
} }
is PointerOutEvt -> {
ctx.setHighlightedEntity(null)
shouldCheckHighlight = false
}
is EntityDragEnterEvt -> { is EntityDragEnterEvt -> {
val quest = ctx.quest.value val quest = ctx.quest.value
val area = ctx.area.value val area = ctx.area.value

View File

@ -6,6 +6,7 @@ import world.phantasmal.web.core.plusAssign
import world.phantasmal.web.core.rendering.OrbitalCameraInputManager import world.phantasmal.web.core.rendering.OrbitalCameraInputManager
import world.phantasmal.web.externals.three.* import world.phantasmal.web.externals.three.*
import world.phantasmal.web.questEditor.actions.CreateEntityAction import world.phantasmal.web.questEditor.actions.CreateEntityAction
import world.phantasmal.web.questEditor.actions.DeleteEntityAction
import world.phantasmal.web.questEditor.actions.RotateEntityAction import world.phantasmal.web.questEditor.actions.RotateEntityAction
import world.phantasmal.web.questEditor.actions.TranslateEntityAction import world.phantasmal.web.questEditor.actions.TranslateEntityAction
import world.phantasmal.web.questEditor.models.* import world.phantasmal.web.questEditor.models.*
@ -22,6 +23,7 @@ class StateContext(
val quest: Val<QuestModel?> = questEditorStore.currentQuest val quest: Val<QuestModel?> = questEditorStore.currentQuest
val area: Val<AreaModel?> = questEditorStore.currentArea val area: Val<AreaModel?> = questEditorStore.currentArea
val wave: Val<WaveModel?> = questEditorStore.selectedWave val wave: Val<WaveModel?> = questEditorStore.selectedWave
val selectedEntity: Val<QuestEntityModel<*, *>?> = questEditorStore.selectedEntity
fun setHighlightedEntity(entity: QuestEntityModel<*, *>?) { fun setHighlightedEntity(entity: QuestEntityModel<*, *>?) {
questEditorStore.setHighlightedEntity(entity) questEditorStore.setHighlightedEntity(entity)
@ -180,6 +182,14 @@ class StateContext(
)) ))
} }
fun deleteEntity(quest: QuestModel, entity: QuestEntityModel<*, *>) {
questEditorStore.executeAction(DeleteEntityAction(
::setSelectedEntity,
quest,
entity,
))
}
/** /**
* @param origin position in normalized device space. * @param origin position in normalized device space.
*/ */

View File

@ -1,35 +1,103 @@
package world.phantasmal.web.questEditor.stores package world.phantasmal.web.questEditor.stores
import world.phantasmal.lib.assembly.disassemble import world.phantasmal.lib.assembly.disassemble
import world.phantasmal.observable.ChangeEvent
import world.phantasmal.observable.Observable
import world.phantasmal.observable.emitter
import world.phantasmal.observable.value.Val import world.phantasmal.observable.value.Val
import world.phantasmal.observable.value.map import world.phantasmal.observable.value.mutableVal
import world.phantasmal.observable.value.trueVal import world.phantasmal.observable.value.trueVal
import world.phantasmal.web.core.undo.SimpleUndo
import world.phantasmal.web.core.undo.UndoManager
import world.phantasmal.web.externals.monacoEditor.* import world.phantasmal.web.externals.monacoEditor.*
import world.phantasmal.web.questEditor.models.QuestModel
import world.phantasmal.webui.obj import world.phantasmal.webui.obj
import world.phantasmal.webui.stores.Store import world.phantasmal.webui.stores.Store
import kotlin.js.RegExp import kotlin.js.RegExp
class AssemblyEditorStore(questEditorStore: QuestEditorStore) : Store() { class AsmStore(
private var _textModel: ITextModel? = null questEditorStore: QuestEditorStore,
private val undoManager: UndoManager,
) : Store() {
private var _textModel = mutableVal<ITextModel?>(null)
private val _didUndo = emitter<Unit>()
private val _didRedo = emitter<Unit>()
private val undo = SimpleUndo(
undoManager,
"Script edits",
{ _didUndo.emit(ChangeEvent(Unit)) },
{ _didRedo.emit(ChangeEvent(Unit)) },
)
val inlineStackArgs: Val<Boolean> = trueVal() val inlineStackArgs: Val<Boolean> = trueVal()
val textModel: Val<ITextModel?> = val textModel: Val<ITextModel?> = _textModel
map(questEditorStore.currentQuest, inlineStackArgs) { quest, inlineArgs ->
_textModel?.dispose()
_textModel =
if (quest == null) null
else {
val assembly = disassemble(quest.bytecodeIr, inlineArgs)
createModel(assembly.joinToString("\n"), ASM_LANG_ID)
}
_textModel
}
val editingEnabled: Val<Boolean> = questEditorStore.questEditingEnabled val editingEnabled: Val<Boolean> = questEditorStore.questEditingEnabled
val didUndo: Observable<Unit> = _didUndo
val didRedo: Observable<Unit> = _didRedo
init {
observe(questEditorStore.currentQuest, inlineStackArgs) { quest, inlineArgs ->
_textModel.value?.dispose()
_textModel.value = quest?.let { createModel(quest, inlineArgs) }
}
}
fun makeUndoCurrent() {
undoManager.setCurrent(undo)
}
private fun createModel(quest: QuestModel, inlineArgs: Boolean): ITextModel {
val assembly = disassemble(quest.bytecodeIr, inlineArgs)
val model = createModel(assembly.joinToString("\n"), ASM_LANG_ID)
addModelChangeListener(model)
return model
}
/**
* Sets up undo/redo, code analysis and breakpoint updates on model change.
*/
private fun addModelChangeListener(model: ITextModel) {
val initialVersion = model.getAlternativeVersionId()
var currentVersion = initialVersion
var lastVersion = initialVersion
model.onDidChangeContent {
val version = model.getAlternativeVersionId()
if (version < currentVersion) {
// Undoing.
undo.canRedo.value = true
if (version == initialVersion) {
undo.canUndo.value = false
}
} else {
// Redoing.
if (version <= lastVersion) {
if (version == lastVersion) {
undo.canRedo.value = false
}
} else {
undo.canRedo.value = false
if (currentVersion > lastVersion) {
lastVersion = currentVersion
}
}
undo.canUndo.value = true
}
currentVersion = version
// TODO: Code analysis and breakpoint update.
}
}
companion object { companion object {
const val ASM_LANG_ID = "psoasm" const val ASM_LANG_ID = "psoasm"

View File

@ -18,16 +18,15 @@ import world.phantasmal.webui.stores.Store
private val logger = KotlinLogging.logger {} private val logger = KotlinLogging.logger {}
class QuestEditorStore( class QuestEditorStore(
private val uiStore: UiStore, uiStore: UiStore,
private val areaStore: AreaStore, private val areaStore: AreaStore,
private val undoManager: UndoManager,
) : Store() { ) : Store() {
private val _currentQuest = mutableVal<QuestModel?>(null) private val _currentQuest = mutableVal<QuestModel?>(null)
private val _currentArea = mutableVal<AreaModel?>(null) private val _currentArea = mutableVal<AreaModel?>(null)
private val _selectedWave = mutableVal<WaveModel?>(null) private val _selectedWave = mutableVal<WaveModel?>(null)
private val _highlightedEntity = mutableVal<QuestEntityModel<*, *>?>(null) private val _highlightedEntity = mutableVal<QuestEntityModel<*, *>?>(null)
private val _selectedEntity = mutableVal<QuestEntityModel<*, *>?>(null) private val _selectedEntity = mutableVal<QuestEntityModel<*, *>?>(null)
private val undoManager = UndoManager()
private val mainUndo = UndoStack(undoManager) private val mainUndo = UndoStack(undoManager)
val runner = QuestRunner() val runner = QuestRunner()
@ -76,21 +75,19 @@ class QuestEditorStore(
} }
fun makeMainUndoCurrent() { fun makeMainUndoCurrent() {
mainUndo.makeCurrent() undoManager.setCurrent(mainUndo)
} }
fun undo() { fun undo() {
require(canUndo.value) { "Can't undo at the moment." }
undoManager.undo() undoManager.undo()
} }
fun redo() { fun redo() {
require(canRedo.value) { "Can't redo at the moment." }
undoManager.redo() undoManager.redo()
} }
suspend fun setCurrentQuest(quest: QuestModel?) { suspend fun setCurrentQuest(quest: QuestModel?) {
mainUndo.reset() undoManager.reset()
// TODO: Stop runner. // TODO: Stop runner.

View File

@ -2,20 +2,19 @@ package world.phantasmal.web.questEditor.widgets
import org.w3c.dom.Node import org.w3c.dom.Node
import world.phantasmal.core.disposable.disposable import world.phantasmal.core.disposable.disposable
import world.phantasmal.web.externals.monacoEditor.IStandaloneCodeEditor import world.phantasmal.web.externals.monacoEditor.*
import world.phantasmal.web.externals.monacoEditor.create import world.phantasmal.web.questEditor.controllers.AsmController
import world.phantasmal.web.externals.monacoEditor.defineTheme
import world.phantasmal.web.externals.monacoEditor.set
import world.phantasmal.web.questEditor.controllers.AssemblyEditorController
import world.phantasmal.webui.dom.div import world.phantasmal.webui.dom.div
import world.phantasmal.webui.obj import world.phantasmal.webui.obj
import world.phantasmal.webui.widgets.Widget import world.phantasmal.webui.widgets.Widget
class AssemblyEditorWidget(private val ctrl: AssemblyEditorController) : Widget() { class AsmEditorWidget(private val ctrl: AsmController) : Widget() {
private lateinit var editor: IStandaloneCodeEditor private lateinit var editor: IStandaloneCodeEditor
override fun Node.createElement() = override fun Node.createElement() =
div { div {
className = "pw-quest-editor-asm-editor"
editor = create(this, obj { editor = create(this, obj {
theme = "phantasmal-world" theme = "phantasmal-world"
scrollBeyondLastLine = false scrollBeyondLastLine = false
@ -34,11 +33,51 @@ class AssemblyEditorWidget(private val ctrl: AssemblyEditorController) : Widget(
observe(ctrl.readOnly) { editor.updateOptions(obj { readOnly = it }) } observe(ctrl.readOnly) { editor.updateOptions(obj { readOnly = it }) }
addDisposable(size.observe { (size) -> addDisposable(size.observe { (size) ->
if (size.width > .0 && size.height > .0) {
editor.layout(obj { editor.layout(obj {
width = size.width width = size.width
height = size.height height = size.height
}) })
}
}) })
// Add VSCode keybinding for command palette.
val quickCommand = editor.getAction("editor.action.quickCommand")
editor.addAction(object : IActionDescriptor {
override var id = "editor.action.quickCommand"
override var label = "Command Palette"
override var keybindings =
arrayOf(KeyMod.CtrlCmd or KeyMod.Shift or KeyCode.KEY_P)
override fun run(editor: ICodeEditor, vararg args: dynamic) {
quickCommand.run()
}
})
// Undo/redo.
observe(ctrl.didUndo) {
editor.focus()
editor.trigger(
source = AsmEditorWidget::class.simpleName,
handlerId = "undo",
payload = undefined
)
}
observe(ctrl.didRedo) {
editor.trigger(
source = AsmEditorWidget::class.simpleName,
handlerId = "redo",
payload = undefined
)
}
editor.onDidFocusEditorWidget(ctrl::makeUndoCurrent)
}
override fun focus() {
editor.focus()
} }
companion object { companion object {
@ -61,6 +100,14 @@ class AssemblyEditorWidget(private val ctrl: AssemblyEditorController) : Widget(
this["editor.lineHighlightBackground"] = "#202020" this["editor.lineHighlightBackground"] = "#202020"
} }
}) })
@Suppress("CssUnusedSymbol")
// language=css
style("""
.pw-quest-editor-asm-editor {
flex-grow: 1;
}
""".trimIndent())
} }
} }
} }

View File

@ -0,0 +1,28 @@
package world.phantasmal.web.questEditor.widgets
import org.w3c.dom.Node
import world.phantasmal.web.questEditor.controllers.AsmController
import world.phantasmal.webui.dom.div
import world.phantasmal.webui.widgets.Checkbox
import world.phantasmal.webui.widgets.Toolbar
import world.phantasmal.webui.widgets.Widget
class AsmToolbarWidget(private val ctrl: AsmController) : Widget() {
override fun Node.createElement() =
div {
className = "pw-quest-editor-asm-toolbar"
addChild(Toolbar(
enabled = ctrl.enabled,
children = listOf(
Checkbox(
enabled = ctrl.inlineStackArgsEnabled,
tooltip = ctrl.inlineStackArgsTooltip,
label = "Inline args",
checked = ctrl.inlineStackArgs,
onChange = ctrl::setInlineStackArgs,
)
)
))
}
}

View File

@ -0,0 +1,36 @@
package world.phantasmal.web.questEditor.widgets
import org.w3c.dom.Node
import world.phantasmal.web.questEditor.controllers.AsmController
import world.phantasmal.webui.dom.div
import world.phantasmal.webui.widgets.Widget
class AsmWidget(private val ctrl: AsmController) : Widget() {
private lateinit var editorWidget: AsmEditorWidget
override fun Node.createElement() =
div {
className = "pw-quest-editor-asm"
addChild(AsmToolbarWidget(ctrl))
editorWidget = addChild(AsmEditorWidget(ctrl))
}
override fun focus() {
editorWidget.focus()
}
companion object {
init {
@Suppress("CssUnusedSymbol")
// language=css
style("""
.pw-quest-editor-asm {
display: flex;
flex-direction: column;
overflow: hidden;
}
""".trimIndent())
}
}
}

View File

@ -13,6 +13,8 @@ class EntityInfoWidget(private val ctrl: EntityInfoController) : Widget(enabled
className = "pw-quest-editor-entity-info" className = "pw-quest-editor-entity-info"
tabIndex = -1 tabIndex = -1
addEventListener("focus", { ctrl.focused() }, true)
table { table {
hidden(ctrl.unavailable) hidden(ctrl.unavailable)
@ -113,11 +115,6 @@ class EntityInfoWidget(private val ctrl: EntityInfoController) : Widget(enabled
)) ))
} }
override fun focus() {
super.focus()
ctrl.focused()
}
companion object { companion object {
private const val COORD_CLASS = "pw-quest-editor-entity-info-coord" private const val COORD_CLASS = "pw-quest-editor-entity-info-coord"

View File

@ -17,7 +17,6 @@ class EntityListWidget(
override fun Node.createElement() = override fun Node.createElement() =
div { div {
className = "pw-quest-editor-entity-list" className = "pw-quest-editor-entity-list"
tabIndex = -1
div { div {
className = "pw-quest-editor-entity-list-inner" className = "pw-quest-editor-entity-list-inner"

View File

@ -12,6 +12,9 @@ class NpcCountsWidget(
override fun Node.createElement() = override fun Node.createElement() =
div { div {
className = "pw-quest-editor-npc-counts" className = "pw-quest-editor-npc-counts"
tabIndex = -1
addEventListener("focus", { ctrl.focused() }, true)
table { table {
hidden(ctrl.unavailable) hidden(ctrl.unavailable)

View File

@ -25,7 +25,7 @@ class QuestEditorToolbarWidget(private val ctrl: QuestEditorToolbarController) :
text = "Open file...", text = "Open file...",
tooltip = value("Open a quest file (Ctrl-O)"), tooltip = value("Open a quest file (Ctrl-O)"),
iconLeft = Icon.File, iconLeft = Icon.File,
accept = ".bin, .dat, .qst", accept = ctrl.openFileAccept,
multiple = true, multiple = true,
filesSelected = { files -> scope.launch { ctrl.openFiles(files) } }, filesSelected = { files -> scope.launch { ctrl.openFiles(files) } },
), ),
@ -45,11 +45,11 @@ class QuestEditorToolbarWidget(private val ctrl: QuestEditorToolbarController) :
), ),
Select( Select(
enabled = ctrl.areaSelectEnabled, enabled = ctrl.areaSelectEnabled,
itemsVal = ctrl.areas, items = ctrl.areas,
itemToString = { it.label }, itemToString = { it.label },
selectedVal = ctrl.currentArea, selected = ctrl.currentArea,
onSelect = ctrl::setCurrentArea, onSelect = ctrl::setCurrentArea,
) ),
) )
)) ))
} }

View File

@ -3,7 +3,7 @@ package world.phantasmal.web.questEditor.widgets
import org.w3c.dom.Node import org.w3c.dom.Node
import world.phantasmal.web.core.widgets.DockWidget import world.phantasmal.web.core.widgets.DockWidget
import world.phantasmal.web.questEditor.controllers.QuestEditorController import world.phantasmal.web.questEditor.controllers.QuestEditorController
import world.phantasmal.web.questEditor.controllers.QuestEditorController.Companion.ASSEMBLY_EDITOR_WIDGET_ID import world.phantasmal.web.questEditor.controllers.QuestEditorController.Companion.ASM_WIDGET_ID
import world.phantasmal.web.questEditor.controllers.QuestEditorController.Companion.ENTITY_INFO_WIDGET_ID import world.phantasmal.web.questEditor.controllers.QuestEditorController.Companion.ENTITY_INFO_WIDGET_ID
import world.phantasmal.web.questEditor.controllers.QuestEditorController.Companion.EVENTS_WIDGET_ID import world.phantasmal.web.questEditor.controllers.QuestEditorController.Companion.EVENTS_WIDGET_ID
import world.phantasmal.web.questEditor.controllers.QuestEditorController.Companion.NPC_COUNTS_WIDGET_ID import world.phantasmal.web.questEditor.controllers.QuestEditorController.Companion.NPC_COUNTS_WIDGET_ID
@ -24,7 +24,7 @@ class QuestEditorWidget(
private val createNpcCountsWidget: () -> NpcCountsWidget, private val createNpcCountsWidget: () -> NpcCountsWidget,
private val createEntityInfoWidget: () -> EntityInfoWidget, private val createEntityInfoWidget: () -> EntityInfoWidget,
private val createQuestRendererWidget: () -> QuestRendererWidget, private val createQuestRendererWidget: () -> QuestRendererWidget,
private val createAssemblyEditorWidget: () -> AssemblyEditorWidget, private val createAsmWidget: () -> AsmWidget,
private val createNpcListWidget: () -> EntityListWidget, private val createNpcListWidget: () -> EntityListWidget,
private val createObjectListWidget: () -> EntityListWidget, private val createObjectListWidget: () -> EntityListWidget,
) : Widget() { ) : Widget() {
@ -41,7 +41,7 @@ class QuestEditorWidget(
NPC_COUNTS_WIDGET_ID -> createNpcCountsWidget() NPC_COUNTS_WIDGET_ID -> createNpcCountsWidget()
ENTITY_INFO_WIDGET_ID -> createEntityInfoWidget() ENTITY_INFO_WIDGET_ID -> createEntityInfoWidget()
QUEST_RENDERER_WIDGET_ID -> createQuestRendererWidget() QUEST_RENDERER_WIDGET_ID -> createQuestRendererWidget()
ASSEMBLY_EDITOR_WIDGET_ID -> createAssemblyEditorWidget() ASM_WIDGET_ID -> createAsmWidget()
NPC_LIST_WIDGET_ID -> createNpcListWidget() NPC_LIST_WIDGET_ID -> createNpcListWidget()
OBJECT_LIST_WIDGET_ID -> createObjectListWidget() OBJECT_LIST_WIDGET_ID -> createObjectListWidget()
EVENTS_WIDGET_ID -> null // TODO: EventsWidget. EVENTS_WIDGET_ID -> null // TODO: EventsWidget.

View File

@ -15,6 +15,8 @@ class QuestInfoWidget(private val ctrl: QuestInfoController) : Widget(enabled =
className = "pw-quest-editor-quest-info" className = "pw-quest-editor-quest-info"
tabIndex = -1 tabIndex = -1
addEventListener("focus", { ctrl.focused() }, true)
table { table {
hidden(ctrl.unavailable) hidden(ctrl.unavailable)
@ -92,11 +94,6 @@ class QuestInfoWidget(private val ctrl: QuestInfoController) : Widget(enabled =
)) ))
} }
override fun focus() {
super.focus()
ctrl.focused()
}
companion object { companion object {
init { init {
@Suppress("CssUnusedSymbol") @Suppress("CssUnusedSymbol")

View File

@ -8,12 +8,9 @@ kotlin {
} }
} }
val coroutinesVersion: String by project.ext
dependencies { dependencies {
api(project(":core")) api(project(":core"))
api(project(":observable")) api(project(":observable"))
api("org.jetbrains.kotlinx:kotlinx-coroutines-core:$coroutinesVersion")
implementation(npm("@fortawesome/fontawesome-free", "^5.13.1")) implementation(npm("@fortawesome/fontawesome-free", "^5.13.1"))
testImplementation(kotlin("test-js")) testImplementation(kotlin("test-js"))

View File

@ -9,18 +9,20 @@ import org.w3c.dom.asList
import org.w3c.files.File import org.w3c.files.File
import org.w3c.files.FileReader import org.w3c.files.FileReader
fun openFiles(accept: String = "", multiple: Boolean = false, callback: (List<File>) -> Unit) { @OptIn(ExperimentalCoroutinesApi::class)
suspend fun selectFiles(accept: String = "", multiple: Boolean = false): List<File> =
suspendCancellableCoroutine { cont ->
val el = document.createElement("input") as HTMLInputElement val el = document.createElement("input") as HTMLInputElement
el.type = "file" el.type = "file"
el.accept = accept el.accept = accept
el.multiple = multiple el.multiple = multiple
el.onchange = { el.onchange = {
callback(el.files?.asList() ?: emptyList()) cont.resume(el.files?.asList() ?: emptyList()) {}
} }
el.click() el.click()
} }
@OptIn(ExperimentalCoroutinesApi::class) @OptIn(ExperimentalCoroutinesApi::class)
suspend fun readFile(file: File): ArrayBuffer = suspendCancellableCoroutine { cont -> suspend fun readFile(file: File): ArrayBuffer = suspendCancellableCoroutine { cont ->

View File

@ -23,6 +23,19 @@ fun <E : Event> EventTarget.disposableListener(
} }
} }
fun <E : Event> EventTarget.disposableListener(
type: String,
listener: (E) -> Unit,
useCapture: Boolean,
): Disposable {
@Suppress("UNCHECKED_CAST")
addEventListener(type, listener as (Event) -> Unit, useCapture)
return disposable {
removeEventListener(type, listener)
}
}
fun Element.disposablePointerDrag( fun Element.disposablePointerDrag(
onPointerDown: (e: PointerEvent) -> Boolean, onPointerDown: (e: PointerEvent) -> Boolean,
onPointerMove: (movedX: Int, movedY: Int, e: PointerEvent) -> Boolean, onPointerMove: (movedX: Int, movedY: Int, e: PointerEvent) -> Boolean,

View File

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

View File

@ -0,0 +1,34 @@
package world.phantasmal.webui.widgets
import org.w3c.dom.Node
import world.phantasmal.observable.value.Val
import world.phantasmal.observable.value.nullVal
import world.phantasmal.observable.value.trueVal
import world.phantasmal.webui.dom.input
class Checkbox(
visible: Val<Boolean> = trueVal(),
enabled: Val<Boolean> = trueVal(),
tooltip: Val<String?> = nullVal(),
label: String? = null,
labelVal: Val<String>? = null,
preferredLabelPosition: LabelPosition = LabelPosition.After,
private val checked: Val<Boolean>? = null,
private val onChange: ((Boolean) -> Unit)? = null,
) : LabelledControl(visible, enabled, tooltip, label, labelVal, preferredLabelPosition) {
override fun Node.createElement() =
input {
className = "pw-checkbox"
type = "checkbox"
if (this@Checkbox.checked != null) {
observe(this@Checkbox.checked) {
checked = it
}
}
if (onChange != null) {
onchange = { onChange.invoke(checked) }
}
}
}

View File

@ -1,12 +1,13 @@
package world.phantasmal.webui.widgets package world.phantasmal.webui.widgets
import kotlinx.coroutines.launch
import org.w3c.dom.HTMLElement import org.w3c.dom.HTMLElement
import org.w3c.files.File import org.w3c.files.File
import world.phantasmal.observable.value.Val import world.phantasmal.observable.value.Val
import world.phantasmal.observable.value.nullVal import world.phantasmal.observable.value.nullVal
import world.phantasmal.observable.value.trueVal import world.phantasmal.observable.value.trueVal
import world.phantasmal.webui.dom.Icon import world.phantasmal.webui.dom.Icon
import world.phantasmal.webui.openFiles import world.phantasmal.webui.selectFiles
class FileButton( class FileButton(
visible: Val<Boolean> = trueVal(), visible: Val<Boolean> = trueVal(),
@ -25,7 +26,9 @@ class FileButton(
if (filesSelected != null) { if (filesSelected != null) {
element.onclick = { element.onclick = {
openFiles(accept, multiple, filesSelected) scope.launch {
filesSelected.invoke(selectFiles(accept, multiple))
}
} }
} }
} }

View File

@ -4,6 +4,7 @@ import org.w3c.dom.Node
import org.w3c.dom.events.KeyboardEvent import org.w3c.dom.events.KeyboardEvent
import org.w3c.dom.events.MouseEvent import org.w3c.dom.events.MouseEvent
import world.phantasmal.observable.value.* import world.phantasmal.observable.value.*
import world.phantasmal.observable.value.list.emptyListVal
import world.phantasmal.webui.dom.Icon import world.phantasmal.webui.dom.Icon
import world.phantasmal.webui.dom.div import world.phantasmal.webui.dom.div
@ -14,11 +15,9 @@ class Select<T : Any>(
label: String? = null, label: String? = null,
labelVal: Val<String>? = null, labelVal: Val<String>? = null,
preferredLabelPosition: LabelPosition = LabelPosition.Before, preferredLabelPosition: LabelPosition = LabelPosition.Before,
items: List<T>? = null, items: Val<List<T>>? = null,
itemsVal: Val<List<T>>? = null,
private val itemToString: (T) -> String = Any::toString, private val itemToString: (T) -> String = Any::toString,
selected: T? = null, selected: Val<T?>? = null,
selectedVal: Val<T?>? = null,
private val onSelect: (T) -> Unit = {}, private val onSelect: (T) -> Unit = {},
) : LabelledControl( ) : LabelledControl(
visible, visible,
@ -28,8 +27,8 @@ class Select<T : Any>(
labelVal, labelVal,
preferredLabelPosition, preferredLabelPosition,
) { ) {
private val items: Val<List<T>> = itemsVal ?: value(items ?: emptyList()) private val items: Val<List<T>> = items ?: emptyListVal()
private val selected: Val<T?> = selectedVal ?: value(selected) private val selected: Val<T?> = selected ?: nullVal()
private val buttonText = mutableVal(" ") private val buttonText = mutableVal(" ")
private val menuVisible = mutableVal(false) private val menuVisible = mutableVal(false)

View File

@ -5,6 +5,9 @@ import world.phantasmal.observable.value.Val
import world.phantasmal.observable.value.trueVal import world.phantasmal.observable.value.trueVal
import world.phantasmal.webui.dom.div import world.phantasmal.webui.dom.div
/**
* Takes ownership of the given [children].
*/
class Toolbar( class Toolbar(
visible: Val<Boolean> = trueVal(), visible: Val<Boolean> = trueVal(),
enabled: Val<Boolean> = trueVal(), enabled: Val<Boolean> = trueVal(),

View File

@ -1,11 +1,11 @@
package world.phantasmal.webui.widgets package world.phantasmal.webui.widgets
import kotlinx.browser.document import kotlinx.browser.document
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.MainScope
import kotlinx.coroutines.cancel import kotlinx.coroutines.cancel
import org.w3c.dom.* import org.w3c.dom.*
import org.w3c.dom.pointerevents.PointerEvent import org.w3c.dom.pointerevents.PointerEvent
import world.phantasmal.core.disposable.DisposableSupervisedScope
import world.phantasmal.core.disposable.Disposer import world.phantasmal.core.disposable.Disposer
import world.phantasmal.observable.Observable import world.phantasmal.observable.Observable
import world.phantasmal.observable.value.* import world.phantasmal.observable.value.*
@ -65,7 +65,7 @@ abstract class Widget(
el el
} }
protected val scope: CoroutineScope = MainScope() protected val scope = addDisposable(DisposableSupervisedScope(this::class, Dispatchers.Main))
/** /**
* This widget's outermost DOM element. * This widget's outermost DOM element.